From 284f9f40d9494168f3e68eedd9af067c38362eea Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 30 Oct 2025 10:35:26 +0900 Subject: (김준회) refactor: POS: 온디맨드로 다운로드받도록 변경, 매핑로직에선 pos 관련 로직 제거 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/pos/download-on-demand/route.ts | 196 +++++++++++ lib/pos/components/pos-file-selection-dialog.tsx | 134 ++++++++ lib/pos/download-on-demand-action.ts | 193 +++++++++++ lib/pos/index.ts | 24 +- lib/pos/sync-rfq-pos-files.ts | 407 ----------------------- lib/rfq-last/table/rfq-items-dialog.tsx | 158 +++++---- lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 180 +--------- lib/soap/ecc/mapper/rfq-and-pr-mapper.ts | 42 +-- 8 files changed, 665 insertions(+), 669 deletions(-) create mode 100644 app/api/pos/download-on-demand/route.ts create mode 100644 lib/pos/components/pos-file-selection-dialog.tsx create mode 100644 lib/pos/download-on-demand-action.ts delete mode 100644 lib/pos/sync-rfq-pos-files.ts diff --git a/app/api/pos/download-on-demand/route.ts b/app/api/pos/download-on-demand/route.ts new file mode 100644 index 00000000..f88e8858 --- /dev/null +++ b/app/api/pos/download-on-demand/route.ts @@ -0,0 +1,196 @@ +/** + * POS 파일 온디맨드 다운로드 API + * + * 자재코드(MATNR)로 POS 파일을 찾아서 NFS에서 직접 스트리밍합니다. + * 서버 스토리지에 저장하지 않고 실시간으로 다운로드합니다. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getDcmtmIdByMaterialCode, getEncryptDocumentumFile, downloadPosFile } from '@/lib/pos'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); + +// 최대 파일 크기 (10240MB) +const MAX_FILE_SIZE = 10240 * 1024 * 1024; + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// MIME 타입 결정 +function getMimeType(fileName: string): string { + const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + 'pdf': 'application/pdf', + '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', + 'txt': 'text/plain; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'dwg': 'application/acad', + 'dxf': 'application/dxf', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + }; + return mimeTypes[fileExtension] || 'application/octet-stream'; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const materialCode = searchParams.get('materialCode'); + const fileIndex = parseInt(searchParams.get('fileIndex') || '0'); + + // 파라미터 검증 + if (!materialCode || materialCode.trim() === '') { + return NextResponse.json( + { error: '자재코드(materialCode)가 제공되지 않았습니다.' }, + { status: 400 } + ); + } + + console.log(`🔍 POS 파일 온디맨드 다운로드 시작 (자재코드: ${materialCode})`); + + // 1. 자재코드로 DCMTM_ID 조회 + console.log(`📋 DCMTM_ID 조회 중... (자재코드: ${materialCode})`); + const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); + + if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { + console.warn(`⚠️ POS 파일을 찾을 수 없음 (자재코드: ${materialCode})`); + return NextResponse.json( + { + error: dcmtmResult.error || '해당 자재코드에 대한 POS 파일을 찾을 수 없습니다.', + materialCode + }, + { status: 404 } + ); + } + + // 파일 인덱스 범위 검증 + if (fileIndex >= dcmtmResult.files.length) { + return NextResponse.json( + { + error: `파일 인덱스가 범위를 벗어났습니다. 사용 가능한 파일 수: ${dcmtmResult.files.length}`, + availableFilesCount: dcmtmResult.files.length + }, + { status: 400 } + ); + } + + const posFile = dcmtmResult.files[fileIndex]; + console.log(`✅ DCMTM_ID 조회 완료:`, { + materialCode, + dcmtmId: posFile.dcmtmId, + fileName: posFile.fileName, + totalFiles: dcmtmResult.files.length + }); + + // 2. POS API로 파일 경로 가져오기 + console.log(`🌐 POS API 호출 중... (DCMTM_ID: ${posFile.dcmtmId})`); + const posResult = await getEncryptDocumentumFile({ + objectID: posFile.dcmtmId + }); + + if (!posResult.success || !posResult.result) { + console.error(`❌ POS API 호출 실패:`, posResult.error); + return NextResponse.json( + { + error: posResult.error || 'POS 파일 경로를 가져올 수 없습니다.', + materialCode, + dcmtmId: posFile.dcmtmId + }, + { status: 500 } + ); + } + + console.log(`✅ POS API 호출 완료 (경로: ${posResult.result})`); + + // 3. NFS에서 파일 다운로드 + console.log(`⬇️ 파일 다운로드 중... (NFS 경로: ${posResult.result})`); + const downloadResult = await downloadPosFile({ + relativePath: posResult.result + }); + + if (!downloadResult.success || !downloadResult.fileBuffer) { + console.error(`❌ 파일 다운로드 실패:`, downloadResult.error); + return NextResponse.json( + { + error: downloadResult.error || '파일 다운로드에 실패했습니다.', + materialCode, + nfsPath: posResult.result + }, + { status: 500 } + ); + } + + const fileName = downloadResult.fileName || posFile.fileName; + const fileBuffer = downloadResult.fileBuffer; + + console.log(`✅ 파일 다운로드 완료 (크기: ${fileBuffer.length} bytes)`); + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 확장자: ${fileName}`); + return NextResponse.json( + { error: '지원하지 않는 파일 형식입니다.', fileName }, + { status: 403 } + ); + } + + // 파일 크기 검증 + if (fileBuffer.length > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileBuffer.length} bytes`); + return NextResponse.json( + { error: '파일 크기가 너무 큽니다.', fileSize: fileBuffer.length }, + { status: 413 } + ); + } + + // MIME 타입 결정 + const contentType = downloadResult.mimeType || getMimeType(fileName); + + // 파일 스트리밍 응답 생성 + const response = new NextResponse(fileBuffer); + + response.headers.set('Content-Type', contentType); + response.headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); + response.headers.set('Content-Length', fileBuffer.length.toString()); + + // 보안 헤더 + response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + response.headers.set('Pragma', 'no-cache'); + response.headers.set('Expires', '0'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + + console.log(`✅ POS 파일 다운로드 성공: ${fileName} (${fileBuffer.length} bytes)`); + + return response; + } catch (error) { + console.error('❌ POS 파일 온디맨드 다운로드 API 오류:', error); + return NextResponse.json( + { + error: '서버 내부 오류가 발생했습니다.', + details: error instanceof Error ? error.message : '알 수 없는 오류' + }, + { status: 500 } + ); + } +} + diff --git a/lib/pos/components/pos-file-selection-dialog.tsx b/lib/pos/components/pos-file-selection-dialog.tsx new file mode 100644 index 00000000..29936d21 --- /dev/null +++ b/lib/pos/components/pos-file-selection-dialog.tsx @@ -0,0 +1,134 @@ +"use client" + +import * as React from "react" +import { Download, FileText } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" + +interface PosFileInfo { + fileName: string + dcmtmId: string + projNo: string + posNo: string + posRevNo: string + fileSer: string +} + +interface PosFileSelectionDialogProps { + isOpen: boolean + onClose: () => void + materialCode: string + files: PosFileInfo[] + onDownload: (fileIndex: number, fileName: string) => void + downloadingIndex: number | null +} + +export function PosFileSelectionDialog({ + isOpen, + onClose, + materialCode, + files, + onDownload, + downloadingIndex, +}: PosFileSelectionDialogProps) { + return ( + + + + + + POS 파일 선택 + + + 자재코드 {materialCode}에 대한 + POS 파일 {files.length}개가 있습니다. + 다운로드할 파일을 선택해주세요. + + + + + + + + 번호 + 파일명 + 프로젝트 + POS 번호 + 리비전 + 파일 SEQ + 다운로드 + + + + {files.map((file, index) => ( + + + #{index + 1} + + +
+ + + {file.fileName} + +
+
+ + {file.projNo} + + + {file.posNo} + + + + {file.posRevNo} + + + + {file.fileSer} + + + + +
+ ))} +
+
+
+ + {files.length === 0 && ( +
+ +

사용 가능한 POS 파일이 없습니다.

+
+ )} +
+
+ ) +} + diff --git a/lib/pos/download-on-demand-action.ts b/lib/pos/download-on-demand-action.ts new file mode 100644 index 00000000..568bae22 --- /dev/null +++ b/lib/pos/download-on-demand-action.ts @@ -0,0 +1,193 @@ +/** + * POS 파일 온디맨드 다운로드 서버 액션 + * + * 클라이언트 컴포넌트에서 자재코드로 POS 파일을 다운로드할 수 있는 함수들을 제공합니다. + */ + +'use server'; + +import { getDcmtmIdByMaterialCode } from './get-dcmtm-id'; + +export interface DownloadPosOnDemandResult { + success: boolean; + downloadUrl?: string; + fileName?: string; + availableFiles?: Array<{ + fileName: string; + dcmtmId: string; + projNo: string; + posNo: string; + posRevNo: string; + fileSer: string; + }>; + error?: string; +} + +/** + * 자재코드로 POS 파일 다운로드 URL을 생성합니다. + * + * @param materialCode - 자재코드 (MATNR) + * @param fileIndex - 여러 파일이 있을 경우 선택할 파일 인덱스 (기본값: 0) + * @returns 다운로드 URL과 파일 정보 + * + * @example + * ```typescript + * const result = await getDownloadUrlByMaterialCode('SN2693A6410100001'); + * if (result.success && result.downloadUrl) { + * window.open(result.downloadUrl, '_blank'); + * } + * ``` + */ +export async function getDownloadUrlByMaterialCode( + materialCode: string, + fileIndex: number = 0 +): Promise { + try { + if (!materialCode || materialCode.trim() === '') { + return { + success: false, + error: '자재코드가 제공되지 않았습니다.', + }; + } + + // 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.map(file => ({ + fileName: file.fileName, + dcmtmId: file.dcmtmId, + projNo: file.projNo, + posNo: file.posNo, + posRevNo: file.posRevNo, + fileSer: file.fileSer, + })), + }; + } + + const selectedFile = dcmtmResult.files[fileIndex]; + + // 2. 다운로드 URL 생성 + const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(materialCode)}&fileIndex=${fileIndex}`; + + return { + success: true, + downloadUrl, + fileName: selectedFile.fileName, + availableFiles: dcmtmResult.files.map(file => ({ + fileName: file.fileName, + dcmtmId: file.dcmtmId, + projNo: file.projNo, + posNo: file.posNo, + posRevNo: file.posRevNo, + fileSer: file.fileSer, + })), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', + }; + } +} + +/** + * 여러 자재코드에 대한 POS 파일 다운로드 URL을 일괄 조회합니다. + * + * @param materialCodes - 자재코드 배열 + * @returns 각 자재코드별 다운로드 정보 + * + * @example + * ```typescript + * const results = await getDownloadUrlsForMaterialCodes([ + * 'SN2693A6410100001', + * 'SN2693A6410100002' + * ]); + * + * results.forEach(result => { + * if (result.success) { + * console.log(`${result.materialCode}: ${result.downloadUrl}`); + * } + * }); + * ``` + */ +export async function getDownloadUrlsForMaterialCodes( + materialCodes: string[] +): Promise> { + const results = await Promise.all( + materialCodes.map(async (materialCode) => { + const result = await getDownloadUrlByMaterialCode(materialCode); + return { + materialCode, + ...result, + }; + }) + ); + + return results; +} + +/** + * 자재코드의 POS 파일 존재 여부를 확인합니다. + * + * @param materialCode - 자재코드 (MATNR) + * @returns 파일 존재 여부와 파일 수 + */ +export async function checkPosFileExists( + materialCode: string +): Promise<{ + exists: boolean; + fileCount: number; + files?: Array<{ + fileName: string; + dcmtmId: string; + projNo: string; + posNo: string; + posRevNo: string; + fileSer: string; + }>; + error?: string; +}> { + try { + const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); + + if (!dcmtmResult.success || !dcmtmResult.files) { + return { + exists: false, + fileCount: 0, + error: dcmtmResult.error, + }; + } + + return { + exists: dcmtmResult.files.length > 0, + fileCount: dcmtmResult.files.length, + files: dcmtmResult.files.map(file => ({ + fileName: file.fileName, + dcmtmId: file.dcmtmId, + projNo: file.projNo, + posNo: file.posNo, + posRevNo: file.posRevNo, + fileSer: file.fileSer, + })), + }; + } catch (error) { + return { + exists: false, + fileCount: 0, + error: error instanceof Error ? error.message : '알 수 없는 오류', + }; + } +} + diff --git a/lib/pos/index.ts b/lib/pos/index.ts index 75309bdd..d889f8c1 100644 --- a/lib/pos/index.ts +++ b/lib/pos/index.ts @@ -1,4 +1,16 @@ -// POS 관련 모든 기능을 하나로 통합하는 인덱스 파일 +/** + * POS (Purchase Order Specification) 파일 관련 기능 통합 모듈 + * + * 주요 기능: + * - MATNR(자재코드)로 DCMTM_ID 조회 + * - SOAP API를 통한 POS 파일 경로 조회 + * - NFS 네트워크 드라이브에서 파일 다운로드 + * - 온디맨드 방식의 POS 파일 다운로드 (자동 동기화 제거됨) + * + * 주요 변경사항: + * - syncRfqPosFiles 함수 제거됨 (온디맨드 방식으로 대체) + * - getDownloadUrlByMaterialCode 등 새로운 온디맨드 함수 추가 + */ import { getEncryptDocumentumFile } from './get-pos'; import { createDownloadUrl } from './download-pos-file'; @@ -19,10 +31,6 @@ export { getFirstDcmtmId } from './get-dcmtm-id'; -export { - syncRfqPosFiles -} from './sync-rfq-pos-files'; - export { getDesignDocumentByMaterialCode, getDesignDocumentsForRfqItems, @@ -30,6 +38,12 @@ export { getDesignDocumentsForRfqItemsAction } from './design-document-service'; +export { + getDownloadUrlByMaterialCode, + getDownloadUrlsForMaterialCodes, + checkPosFileExists +} from './download-on-demand-action'; + // 타입들은 ./types 에서 export export type * from './types'; diff --git a/lib/pos/sync-rfq-pos-files.ts b/lib/pos/sync-rfq-pos-files.ts deleted file mode 100644 index acb34f20..00000000 --- a/lib/pos/sync-rfq-pos-files.ts +++ /dev/null @@ -1,407 +0,0 @@ -'use server'; - -import db from '@/db/db'; -import { rfqPrItems, rfqLastAttachments, rfqLastAttachmentRevisions } from '@/db/schema/rfqLast'; -import { eq, and, ne } from 'drizzle-orm'; -import { getDcmtmIdByMaterialCode } from './get-dcmtm-id'; -import { getEncryptDocumentumFile } from './get-pos'; -import { downloadPosFile } from './download-pos-file'; -import type { PosFileSyncResult } from './types'; -import path from 'path'; -import fs from 'fs/promises'; -import { revalidatePath } from 'next/cache'; -import { debugLog, debugError, debugSuccess, debugProcess, debugWarn } from '@/lib/debug-utils'; - - -/** - * RFQ의 모든 PR Items의 MATNR로 POS 파일을 조회하고 서버에 다운로드하여 저장 - */ -export async function syncRfqPosFiles( - rfqId: number, - userId: number -): Promise { - debugLog(`🚀 POS 파일 동기화 시작`, { rfqId, userId }); - - const result: PosFileSyncResult = { - success: false, - processedCount: 0, - successCount: 0, - failedCount: 0, - errors: [], - details: [] - }; - - try { - // 1. RFQ의 모든 PR Items 조회 (materialCode 중복 제거) - debugProcess(`📋 RFQ PR Items 조회 시작 (RFQ ID: ${rfqId})`); - - const prItems = await db - .selectDistinct({ - materialCode: rfqPrItems.materialCode, - materialDescription: rfqPrItems.materialDescription, - }) - .from(rfqPrItems) - .where(and( - eq(rfqPrItems.rfqsLastId, rfqId), - // materialCode가 null이 아닌 것만 - )) - .then(items => items.filter(item => item.materialCode && item.materialCode.trim() !== '')); - - debugLog(`📦 조회된 PR Items`, { - totalCount: prItems.length, - materialCodes: prItems.map(item => item.materialCode) - }); - - if (prItems.length === 0) { - debugWarn(`⚠️ 처리할 자재코드가 없습니다 (RFQ ID: ${rfqId})`); - result.errors.push('처리할 자재코드가 없습니다.'); - return result; - } - - result.processedCount = prItems.length; - debugSuccess(`✅ 처리할 자재코드 ${prItems.length}개 발견`); - - // 2. 각 자재코드별로 POS 파일 처리 - debugProcess(`🔄 자재코드별 POS 파일 처리 시작`); - - for (const prItem of prItems) { - const materialCode = prItem.materialCode!; - debugLog(`📋 자재코드 처리 시작: ${materialCode}`); - - try { - // 2-1. 자재코드로 DCMTM_ID 조회 - debugProcess(`🔍 DCMTM_ID 조회 시작 (자재코드: ${materialCode})`); - const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); - - debugLog(`🎯 DCMTM_ID 조회 결과`, { - materialCode, - success: dcmtmResult.success, - fileCount: dcmtmResult.files?.length || 0, - files: dcmtmResult.files - }); - - if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { - debugWarn(`⚠️ DCMTM_ID 조회 실패 또는 파일 없음 (자재코드: ${materialCode})`, dcmtmResult.error); - result.details.push({ - materialCode, - status: 'no_files', - error: dcmtmResult.error || 'POS 파일을 찾을 수 없음' - }); - continue; - } - - // 여러 파일이 있을 경우 첫 번째 파일만 처리 - const posFile = dcmtmResult.files[0]; - debugLog(`📁 처리할 POS 파일 선택`, { - materialCode, - selectedFile: posFile, - totalFiles: dcmtmResult.files.length - }); - - // 2-2. POS API로 파일 경로 가져오기 - debugProcess(`🌐 POS API 호출 시작 (DCMTM_ID: ${posFile.dcmtmId})`); - const posResult = await getEncryptDocumentumFile({ - objectID: posFile.dcmtmId - }); - - debugLog(`🌐 POS API 호출 결과`, { - materialCode, - dcmtmId: posFile.dcmtmId, - success: posResult.success, - resultPath: posResult.result, - error: posResult.error - }); - - if (!posResult.success || !posResult.result) { - debugError(`❌ POS API 호출 실패 (자재코드: ${materialCode})`, posResult.error); - result.details.push({ - materialCode, - fileName: posFile.fileName, - status: 'failed', - error: posResult.error || 'POS 파일 경로 조회 실패' - }); - result.failedCount++; - continue; - } - - // 2-3. 내부망에서 파일 다운로드 - debugProcess(`⬇️ 파일 다운로드 시작 (경로: ${posResult.result})`); - const downloadResult = await downloadPosFile({ - relativePath: posResult.result - }); - - debugLog(`⬇️ 파일 다운로드 결과`, { - materialCode, - success: downloadResult.success, - fileName: downloadResult.fileName, - fileSize: downloadResult.fileBuffer?.length, - mimeType: downloadResult.mimeType, - error: downloadResult.error - }); - - if (!downloadResult.success || !downloadResult.fileBuffer) { - debugError(`❌ 파일 다운로드 실패 (자재코드: ${materialCode})`, downloadResult.error); - result.details.push({ - materialCode, - fileName: posFile.fileName, - status: 'failed', - error: downloadResult.error || '파일 다운로드 실패' - }); - result.failedCount++; - continue; - } - - // 2-4. 서버 파일 시스템에 저장 - debugProcess(`💾 서버 파일 저장 시작 (파일명: ${downloadResult.fileName || `${materialCode}.pdf`})`); - const saveResult = await saveFileToServer( - downloadResult.fileBuffer, - downloadResult.fileName || `${materialCode}.pdf` - ); - - debugLog(`💾 서버 파일 저장 결과`, { - materialCode, - success: saveResult.success, - filePath: saveResult.filePath, - fileName: saveResult.fileName, - error: saveResult.error - }); - - if (!saveResult.success) { - debugError(`❌ 서버 파일 저장 실패 (자재코드: ${materialCode})`, saveResult.error); - result.details.push({ - materialCode, - fileName: posFile.fileName, - status: 'failed', - error: saveResult.error || '서버 파일 저장 실패' - }); - result.failedCount++; - continue; - } - - // 2-5. 데이터베이스에 첨부파일 정보 저장 - debugProcess(`🗄️ DB 첨부파일 정보 저장 시작 (자재코드: ${materialCode})`); - const dbResult = await saveAttachmentToDatabase( - rfqId, - materialCode, - { dcmtmId: posFile.dcmtmId, fileName: posFile.fileName }, - saveResult.filePath!, - saveResult.fileName!, - downloadResult.fileBuffer.length, - downloadResult.mimeType || 'application/pdf', - userId - ); - - debugLog(`🗄️ DB 저장 결과`, { - materialCode, - success: dbResult.success, - error: dbResult.error - }); - - if (!dbResult.success) { - debugError(`❌ DB 저장 실패 (자재코드: ${materialCode})`, dbResult.error); - result.details.push({ - materialCode, - fileName: posFile.fileName, - status: 'failed', - error: dbResult.error || 'DB 저장 실패' - }); - result.failedCount++; - continue; - } - - debugSuccess(`✅ 자재코드 ${materialCode} 처리 완료`); - result.details.push({ - materialCode, - fileName: posFile.fileName, - status: 'success' - }); - result.successCount++; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; - debugError(`❌ 자재코드 ${materialCode} 처리 중 예외 발생`, { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); - result.details.push({ - materialCode, - status: 'failed', - error: errorMessage - }); - result.failedCount++; - result.errors.push(`${materialCode}: ${errorMessage}`); - } - } - - result.success = result.successCount > 0; - - debugLog(`📊 POS 파일 동기화 최종 결과`, { - rfqId, - processedCount: result.processedCount, - successCount: result.successCount, - failedCount: result.failedCount, - success: result.success, - errors: result.errors - }); - - // 캐시 무효화 - debugProcess(`🔄 캐시 무효화 (경로: /evcp/rfq-last/${rfqId})`); - revalidatePath(`/evcp/rfq-last/${rfqId}`); - - debugSuccess(`🎉 POS 파일 동기화 완료 (성공: ${result.successCount}건, 실패: ${result.failedCount}건)`); - return result; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; - debugError(`❌ POS 파일 동기화 전체 처리 오류`, { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); - result.errors.push(`전체 처리 오류: ${errorMessage}`); - return result; - } -} - -/** - * 파일을 서버 파일 시스템에 저장 - */ -async function saveFileToServer( - fileBuffer: Buffer, - originalFileName: string -): Promise<{ - success: boolean; - filePath?: string; - fileName?: string; - error?: string; -}> { - try { - // uploads/pos 디렉토리 생성 - const uploadDir = path.join(process.cwd(), 'uploads', 'pos'); - - try { - await fs.access(uploadDir); - } catch { - await fs.mkdir(uploadDir, { recursive: true }); - } - - // 고유한 파일명 생성 (타임스탬프 + 원본명) - const timestamp = Date.now(); - const sanitizedFileName = originalFileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const fileName = `${timestamp}_${sanitizedFileName}`; - const filePath = path.join(uploadDir, fileName); - - // 파일 저장 - await fs.writeFile(filePath, fileBuffer); - - return { - success: true, - filePath: `uploads/pos/${fileName}`, // 상대 경로로 저장 - fileName: originalFileName - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : '파일 저장 실패' - }; - } -} - -/** - * 첨부파일 정보를 데이터베이스에 저장 - */ -async function saveAttachmentToDatabase( - rfqId: number, - materialCode: string, - posFileInfo: { dcmtmId: string; fileName: string }, - filePath: string, - originalFileName: string, - fileSize: number, - fileType: string, - userId: number -): Promise<{ - success: boolean; - error?: string; -}> { - try { - await db.transaction(async (tx) => { - // 1. 기존 동일한 자재코드의 설계 첨부파일이 있는지 확인 - const existingAttachment = await tx - .select() - .from(rfqLastAttachments) - .where(and( - eq(rfqLastAttachments.rfqId, rfqId), - eq(rfqLastAttachments.attachmentType, '설계'), - eq(rfqLastAttachments.serialNo, materialCode) - )) - .limit(1); - - let attachmentId: number; - - if (existingAttachment.length > 0) { - // 기존 첨부파일이 있으면 업데이트 - attachmentId = existingAttachment[0].id; - - await tx - .update(rfqLastAttachments) - .set({ - currentRevision: 'Rev.1', // 새 리비전으로 업데이트 - description: `${posFileInfo.fileName} (자재코드: ${materialCode})`, - updatedAt: new Date() - }) - .where(eq(rfqLastAttachments.id, attachmentId)); - } else { - // 새 첨부파일 생성 - const [newAttachment] = await tx - .insert(rfqLastAttachments) - .values({ - attachmentType: '설계', - serialNo: materialCode, - rfqId, - currentRevision: 'Rev.0', - description: `${posFileInfo.fileName} (자재코드: ${materialCode})`, - createdBy: userId, - createdAt: new Date(), - updatedAt: new Date() - }) - .returning({ id: rfqLastAttachments.id }); - - attachmentId = newAttachment.id; - } - - // 2. 새 리비전 생성 - const [newRevision] = await tx - .insert(rfqLastAttachmentRevisions) - .values({ - attachmentId, - revisionNo: 'Rev.0', - fileName: path.basename(filePath), - originalFileName, - filePath, - fileSize, - fileType, - isLatest: true, - revisionComment: `POS 시스템에서 자동 동기화됨 (DCMTM_ID: ${posFileInfo.dcmtmId})`, - createdBy: userId - }) - .returning({ id: rfqLastAttachmentRevisions.id }); - - // 3. 첨부파일의 latestRevisionId 업데이트 - await tx - .update(rfqLastAttachments) - .set({ - latestRevisionId: newRevision.id - }) - .where(eq(rfqLastAttachments.id, attachmentId)); - - // 4. 기존 리비전들의 isLatest를 false로 업데이트 - await tx - .update(rfqLastAttachmentRevisions) - .set({ isLatest: false }) - .where(and( - eq(rfqLastAttachmentRevisions.attachmentId, attachmentId), - ne(rfqLastAttachmentRevisions.id, newRevision.id) - )); - }); - - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'DB 저장 실패' - }; - } -} diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx index c640f3bd..5f8e4382 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -26,8 +26,8 @@ import { Separator } from "@/components/ui/separator" import { toast } from "sonner" import { RfqsLastView } from "@/db/schema" import { getRfqItemsAction } from "../service" -import { getDesignDocumentsForRfqItemsAction } from "@/lib/pos" -import { downloadFile } from "@/lib/file-download" +import { getDownloadUrlByMaterialCode, checkPosFileExists } from "@/lib/pos" +import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" // 품목 타입 interface RfqItem { @@ -84,27 +84,29 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps const [items, setItems] = React.useState([]) const [statistics, setStatistics] = React.useState(null) const [isLoading, setIsLoading] = React.useState(false) - // 자재코드별 설계 문서 매핑 - const [designDocuments, setDesignDocuments] = React.useState>({}) - const [isLoadingDocs, setIsLoadingDocs] = React.useState(false) + // POS 파일 선택 다이얼로그 상태 + const [posDialogOpen, setPosDialogOpen] = React.useState(false) + const [selectedMaterialCode, setSelectedMaterialCode] = React.useState("") + const [posFiles, setPosFiles] = React.useState>([]) + const [loadingPosFiles, setLoadingPosFiles] = React.useState(false) + const [downloadingFileIndex, setDownloadingFileIndex] = React.useState(null) - // 품목 목록 및 설계 문서 로드 + // 품목 목록 로드 React.useEffect(() => { if (!isOpen || !rfqData.id) return const loadData = async () => { setIsLoading(true) - setIsLoadingDocs(true) try { - // 1. 품목 목록 로드 + // 품목 목록 로드 const itemsResult = await getRfqItemsAction(rfqData.id) if (itemsResult.success) { @@ -115,26 +117,14 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps setItems([]) setStatistics(null) } - - // 2. 설계 문서 매핑 로드 - const docsResult = await getDesignDocumentsForRfqItemsAction(rfqData.id) - - if (docsResult.success && docsResult.documents) { - setDesignDocuments(docsResult.documents) - } else { - console.warn("설계 문서 매핑 로드 실패:", docsResult.error) - setDesignDocuments({}) - } } catch (error) { console.error("데이터 로드 오류:", error) toast.error("데이터를 불러오는데 실패했습니다") setItems([]) setStatistics(null) - setDesignDocuments({}) } finally { setIsLoading(false) - setIsLoadingDocs(false) } } @@ -146,26 +136,74 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps window.open(specUrl, '_blank', 'noopener,noreferrer') } - // 설계 문서 다운로드 - const handleDownloadDesignDoc = async (materialCode: string, fileName: string, filePath: string) => { + // POS 파일 목록 조회 및 다이얼로그 열기 + const handleOpenPosDialog = async (materialCode: string) => { + if (!materialCode) { + toast.error("자재코드가 없습니다") + return + } + + setLoadingPosFiles(true) + setSelectedMaterialCode(materialCode) + + try { + toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) + + const result = await checkPosFileExists(materialCode) + + if (result.exists && result.files && result.files.length > 0) { + // 파일 정보를 상세하게 가져오기 위해 getDownloadUrlByMaterialCode 사용 + const detailResult = await getDownloadUrlByMaterialCode(materialCode) + + if (detailResult.success && detailResult.availableFiles) { + setPosFiles(detailResult.availableFiles) + setPosDialogOpen(true) + toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) + } else { + toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } else { + toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } catch (error) { + console.error("POS 파일 조회 오류:", error) + toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) + } finally { + setLoadingPosFiles(false) + } + } + + // POS 파일 다운로드 실행 + const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { + if (!selectedMaterialCode) return + + setDownloadingFileIndex(fileIndex) + try { - await downloadFile(filePath, fileName, { - action: 'download', - showToast: true - }) + toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) + + const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` + + toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) + window.open(downloadUrl, '_blank', 'noopener,noreferrer') + + // 다운로드 시작 후 잠시 대기 후 상태 초기화 + setTimeout(() => { + setDownloadingFileIndex(null) + }, 1000) } catch (error) { - console.error("설계 문서 다운로드 오류:", error) - toast.error("설계 문서 다운로드에 실패했습니다") + console.error("POS 파일 다운로드 오류:", error) + toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) + setDownloadingFileIndex(null) } } - // 파일 크기 포맷팅 - const formatFileSize = (bytes: number | null) => { - if (!bytes) return "" - const sizes = ['B', 'KB', 'MB', 'GB'] - if (bytes === 0) return '0 B' - const i = Math.floor(Math.log(bytes) / Math.log(1024)) - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i] + // POS 다이얼로그 닫기 + const handleClosePosDialog = () => { + setPosDialogOpen(false) + setSelectedMaterialCode("") + setPosFiles([]) + setDownloadingFileIndex(null) } @@ -361,23 +399,20 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps )} - {/* 설계 문서 다운로드 */} - {item.materialCode && designDocuments[item.materialCode] && ( + {/* POS 파일 다운로드 */} + {item.materialCode && (
- +
)} @@ -388,13 +423,6 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps TRK: {item.trackingNo} )} - - {/* 설계 문서 로딩 상태 */} - {isLoadingDocs && item.materialCode && ( -
- 설계문서 확인 중... -
- )} @@ -435,6 +463,16 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps )} + + {/* POS 파일 선택 다이얼로그 */} + ) diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 5f3c7e78..af4cecdc 100644 --- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts @@ -1,7 +1,13 @@ /** - * pr 발행 후, pr을 묶어서 rfq, bidding 을 sap ecc에서 생성한 경우 - * ZBSART = AB인 경우, 즉 bidding인 경우 해당 케이스를 soap으로 수신한 뒤 이 함수에서 헤더는 biddings 테이블에, 아이템은 prItemsForBidding 테이블에 매핑 - * ZBSART = AN인 경우, 즉 rfq인 경우 해당 케이스를 soap으로 수신한 뒤 rfq-and-pr-mapper.ts 파일에서 매핑 + * SAP ECC에서 PR을 묶어 Bidding을 생성한 경우 SOAP으로 수신하여 처리하는 매퍼 + * + * ZBSART = AB (Bidding)인 경우 이 파일에서 처리하여 biddings, prItemsForBidding 테이블에 매핑합니다. + * ZBSART = AN (RFQ)인 경우는 rfq-and-pr-mapper.ts 파일에서 매핑합니다. + * + * 주요 변경사항: + * - POS 파일 자동 다운로드 로직 제거됨 (온디맨드 방식으로 변경) + * - syncBiddingPosFiles 함수 제거됨 + * - 사용자가 필요할 때만 POS 파일을 다운로드하도록 개선 */ import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils'; @@ -23,11 +29,8 @@ import { parseSAPDateTime, parseSAPDateToString, } from './common-mapper-utils'; -import { - getDcmtmIdByMaterialCode, - getEncryptDocumentumFile, - downloadPosFile -} from '@/lib/pos'; +// Note: POS 파일은 온디맨드 방식으로 다운로드됩니다. +// 자동 동기화 관련 import는 제거되었습니다. // ECC 데이터 타입 정의 export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert; @@ -37,116 +40,8 @@ export type ECCBidItem = typeof PR_INFORMATION_T_BID_ITEM.$inferInsert; export type BiddingData = typeof biddings.$inferInsert; export type PrItemForBiddingData = typeof prItemsForBidding.$inferInsert; -/** - * Bidding용 POS 파일 동기화 함수 - * 자재코드 기준으로 POS 파일을 찾아서 prDocuments 테이블에 저장 - */ -async function syncBiddingPosFiles( - biddingId: number, - materialCodes: string[], - userId: string = '1' -): Promise<{ - success: boolean; - successCount: number; - failedCount: number; - errors: string[]; -}> { - debugLog('Bidding POS 파일 동기화 시작', { biddingId, materialCodes }); - - let successCount = 0; - let failedCount = 0; - const errors: string[] = []; - - // 중복 제거된 자재코드로 처리 - const uniqueMaterialCodes = [...new Set(materialCodes.filter(code => code && code.trim() !== ''))]; - - for (const materialCode of uniqueMaterialCodes) { - try { - debugLog(`자재코드 ${materialCode} POS 파일 조회 시작`); - - // 1. 자재코드로 DCMTM_ID 조회 - const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); - - if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { - debugLog(`자재코드 ${materialCode}: POS 파일 없음`); - continue; // 에러로 카운트하지 않고 스킵 - } - - // 첫 번째 파일만 처리 - const posFile = dcmtmResult.files[0]; - - // 2. POS API로 파일 경로 가져오기 - const posResult = await getEncryptDocumentumFile({ - objectID: posFile.dcmtmId - }); - - if (!posResult.success || !posResult.result) { - errors.push(`${materialCode}: POS 파일 경로 조회 실패`); - failedCount++; - continue; - } - - // 3. 파일 다운로드 - const downloadResult = await downloadPosFile({ - relativePath: posResult.result - }); - - if (!downloadResult.success || !downloadResult.fileBuffer) { - errors.push(`${materialCode}: 파일 다운로드 실패`); - failedCount++; - continue; - } - - // 4. 서버에 파일 저장 (uploads/bidding-pos 디렉토리) - const path = await import('path'); - const fs = await import('fs/promises'); - - const uploadDir = path.join(process.cwd(), 'uploads', 'bidding-pos'); - try { - await fs.access(uploadDir); - } catch { - await fs.mkdir(uploadDir, { recursive: true }); - } - - const timestamp = Date.now(); - const sanitizedFileName = (downloadResult.fileName || `${materialCode}.pdf`).replace(/[^a-zA-Z0-9.-]/g, '_'); - const fileName = `${timestamp}_${sanitizedFileName}`; - const filePath = path.join(uploadDir, fileName); - - await fs.writeFile(filePath, downloadResult.fileBuffer); - - // 5. prDocuments 테이블에 저장 - await db.insert(prDocuments).values({ - biddingId, - documentName: `${materialCode} 설계문서`, - fileName, - originalFileName: posFile.fileName, - fileSize: downloadResult.fileBuffer.length, - mimeType: downloadResult.mimeType || 'application/pdf', - filePath: `uploads/bidding-pos/${fileName}`, - registeredBy: userId, - description: `POS 시스템에서 자동 동기화됨 (DCMTM_ID: ${posFile.dcmtmId}, 자재코드: ${materialCode})`, - version: 'Rev.0' - }); - - successCount++; - debugSuccess(`자재코드 ${materialCode} POS 파일 동기화 완료`); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; - errors.push(`${materialCode}: ${errorMessage}`); - failedCount++; - debugError(`자재코드 ${materialCode} POS 파일 동기화 실패`, error); - } - } - - return { - success: successCount > 0, - successCount, - failedCount, - errors - }; -} +// Note: syncBiddingPosFiles 함수는 제거되었습니다. +// POS 파일은 온디맨드 방식으로 사용자가 필요할 때 다운로드됩니다. /** @@ -501,53 +396,8 @@ export async function mapAndSaveECCBiddingData( processedCount: result.processedCount, }); - // 7) 각 Bidding에 대해 POS 파일 자동 동기화 (비동기로 실행하여 메인 플로우 블록하지 않음) - debugLog('Bidding POS 파일 자동 동기화 시작', { biddingCount: result.insertedBiddings.length }); - - // 비동기로 각 Bidding의 POS 파일 동기화 실행 (결과를 기다리지 않음) - result.insertedBiddings.forEach(async (bidding) => { - try { - // 해당 Bidding과 관련된 모든 자재코드 추출 - const relatedMaterialCodes = result.allEccItems - .filter(item => item.ANFNR === bidding.ANFNR) - .map(item => item.MATNR) - .filter(Boolean) as string[]; - - if (relatedMaterialCodes.length === 0) { - debugLog(`Bidding ${bidding.biddingNumber}: 자재코드 없음`); - return; - } - - debugLog(`Bidding ${bidding.biddingNumber} POS 파일 동기화 시작`, { - biddingId: bidding.id, - materialCodes: relatedMaterialCodes - }); - - const syncResult = await syncBiddingPosFiles( - bidding.id, - relatedMaterialCodes, - bidding.createdBy || '1' - ); - - if (syncResult.success) { - debugSuccess(`Bidding ${bidding.biddingNumber} POS 파일 동기화 완료`, { - biddingId: bidding.id, - successCount: syncResult.successCount, - failedCount: syncResult.failedCount - }); - } else { - debugError(`Bidding ${bidding.biddingNumber} POS 파일 동기화 실패`, { - biddingId: bidding.id, - errors: syncResult.errors - }); - } - } catch (error) { - debugError(`Bidding ${bidding.biddingNumber} POS 파일 동기화 중 예외 발생`, { - biddingId: bidding.id, - error: error instanceof Error ? error.message : '알 수 없는 오류' - }); - } - }); + // Note: POS 파일은 온디맨드 방식으로 사용자가 필요할 때 다운로드됩니다. + // 자동 다운로드는 더 이상 수행하지 않습니다. return { success: true, diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts index f2683213..cc241aa6 100644 --- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts @@ -1,7 +1,12 @@ /** - * pr 발행 후, pr을 묶어서 rfq, bidding 을 sap ecc에서 생성한 경우 - * ZBSART = AN인 경우, 즉 rfq인 경우 해당 케이스를 soap으로 수신한 뒤 이 함수에서 rqfLast, rfqPrItems 테이블에 매핑 - * bidding인 경우는 bidding-and-pr-mapper.ts 파일에서 매핑 + * SAP ECC에서 PR을 묶어 RFQ를 생성한 경우 SOAP으로 수신하여 처리하는 매퍼 + * + * ZBSART = AN (RFQ)인 경우 이 파일에서 처리하여 rfqsLast, rfqPrItems 테이블에 매핑합니다. + * ZBSART = AB (Bidding)인 경우는 bidding-and-pr-mapper.ts 파일에서 매핑합니다. + * + * 주요 변경사항: + * - POS 파일 자동 다운로드 로직 제거됨 (온디맨드 방식으로 변경) + * - 사용자가 필요할 때만 POS 파일을 다운로드하도록 개선 */ import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils'; @@ -20,7 +25,6 @@ import { findProjectInfoByPSPID, parseSAPDateTime, } from './common-mapper-utils'; -import { syncRfqPosFiles } from '@/lib/pos'; // ECC 데이터 타입 정의 export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert; @@ -374,34 +378,8 @@ export async function mapAndSaveECCRfqDataToRfqLast( processedCount: result.processedCount, }); - // 6) 각 RFQ에 대해 POS 파일 자동 동기화 (비동기로 실행하여 메인 플로우 블록하지 않음) - debugLog('RFQ POS 파일 자동 동기화 시작', { rfqCount: result.insertedRfqs.length }); - - // 비동기로 각 RFQ의 POS 파일 동기화 실행 (결과를 기다리지 않음) - result.insertedRfqs.forEach(async (rfq) => { - try { - debugLog(`RFQ ${rfq.rfqCode} POS 파일 동기화 시작`, { rfqId: rfq.id }); - const syncResult = await syncRfqPosFiles(rfq.id, 1); // 시스템 사용자 ID = 1 - - if (syncResult.success) { - debugSuccess(`RFQ ${rfq.rfqCode} POS 파일 동기화 완료`, { - rfqId: rfq.id, - successCount: syncResult.successCount, - failedCount: syncResult.failedCount - }); - } else { - debugError(`RFQ ${rfq.rfqCode} POS 파일 동기화 실패`, { - rfqId: rfq.id, - errors: syncResult.errors - }); - } - } catch (error) { - debugError(`RFQ ${rfq.rfqCode} POS 파일 동기화 중 예외 발생`, { - rfqId: rfq.id, - error: error instanceof Error ? error.message : '알 수 없는 오류' - }); - } - }); + // Note: POS 파일은 온디맨드 방식으로 사용자가 필요할 때 다운로드됩니다. + // 자동 다운로드는 더 이상 수행하지 않습니다. return { success: true, -- cgit v1.2.3