diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-26 14:13:20 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-26 14:13:20 +0900 |
| commit | f8fc02e175f93466cd7693eb6e549c45362e785b (patch) | |
| tree | 1037ec1f9225b0a0142defd6a27c68c3e6a47009 /lib | |
| parent | 11bc8239ad474a8f31c1c73de51f7d0f101594df (diff) | |
(김준회) POS 및 구매 피드백 처리
- 요구사항 28.(0.1) 24번 행 (prItem번호 별도 표기)
- pos nfs 경로에서 가져오도록 수정개발
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/pos/design-document-service.ts | 188 | ||||
| -rw-r--r-- | lib/pos/download-pos-file.ts | 54 | ||||
| -rw-r--r-- | lib/pos/get-pos.ts | 238 | ||||
| -rw-r--r-- | lib/pos/index.ts | 7 | ||||
| -rw-r--r-- | lib/pos/types.ts | 22 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-items-dialog.tsx | 150 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/rfq-and-pr-mapper.ts | 38 |
7 files changed, 628 insertions, 69 deletions
diff --git a/lib/pos/design-document-service.ts b/lib/pos/design-document-service.ts new file mode 100644 index 00000000..8f213d57 --- /dev/null +++ b/lib/pos/design-document-service.ts @@ -0,0 +1,188 @@ +'use server'; + +import db from '@/db/db'; +import { rfqLastAttachments, rfqLastAttachmentRevisions } from '@/db/schema/rfqLast'; +import { eq, and } from 'drizzle-orm'; + +/** + * 자재코드별 설계 문서 조회 + */ +export async function getDesignDocumentByMaterialCode( + rfqId: number, + materialCode: string +): Promise<{ + success: boolean; + document?: { + id: number; + attachmentType: string; + serialNo: string; + description: string | null; + latestRevisionId: number | null; + fileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + }; + error?: string; +}> { + try { + const result = await db + .select({ + attachmentId: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + description: rfqLastAttachments.description, + latestRevisionId: rfqLastAttachments.latestRevisionId, + fileName: rfqLastAttachmentRevisions.fileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + eq(rfqLastAttachments.attachmentType, '설계'), + eq(rfqLastAttachments.serialNo, materialCode) + ) + ) + .limit(1); + + if (result.length === 0) { + return { + success: false, + error: '설계 문서를 찾을 수 없습니다.' + }; + } + + const doc = result[0]; + return { + success: true, + document: { + id: doc.attachmentId, + attachmentType: doc.attachmentType, + serialNo: doc.serialNo, + description: doc.description, + latestRevisionId: doc.latestRevisionId, + fileName: doc.fileName || '', + filePath: doc.filePath || '', + fileSize: doc.fileSize, + fileType: doc.fileType, + } + }; + } catch (error) { + console.error('설계 문서 조회 오류:', error); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }; + } +} + +/** + * RFQ의 모든 PR Items에 대한 설계 문서 매핑 조회 + */ +export async function getDesignDocumentsForRfqItems( + rfqId: number +): Promise<{ + success: boolean; + documents?: Record<string, { + id: number; + fileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + }>; + error?: string; +}> { + try { + const result = await db + .select({ + materialCode: rfqLastAttachments.serialNo, + attachmentId: rfqLastAttachments.id, + description: rfqLastAttachments.description, + fileName: rfqLastAttachmentRevisions.fileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + eq(rfqLastAttachments.attachmentType, '설계') + ) + ); + + const documentsMap: Record<string, any> = {}; + + for (const doc of result) { + if (doc.materialCode) { + documentsMap[doc.materialCode] = { + id: doc.attachmentId, + fileName: doc.fileName || '', + filePath: doc.filePath || '', + fileSize: doc.fileSize, + fileType: doc.fileType, + description: doc.description, + }; + } + } + + return { + success: true, + documents: documentsMap + }; + } catch (error) { + console.error('설계 문서 매핑 조회 오류:', error); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }; + } +} + +/** + * 자재코드별 설계 문서 조회 서버 액션 + */ +export async function getDesignDocumentByMaterialCodeAction( + rfqId: number, + materialCode: string +) { + try { + const result = await getDesignDocumentByMaterialCode(rfqId, materialCode); + return result; + } catch (error) { + console.error("Error in getDesignDocumentByMaterialCodeAction:", error); + return { + success: false, + error: "설계 문서를 불러오는데 실패했습니다.", + }; + } +} + +/** + * RFQ Items 설계 문서 매핑 조회 서버 액션 + */ +export async function getDesignDocumentsForRfqItemsAction(rfqId: number) { + try { + const result = await getDesignDocumentsForRfqItems(rfqId); + return result; + } catch (error) { + console.error("Error in getDesignDocumentsForRfqItemsAction:", error); + return { + success: false, + error: "설계 문서 매핑을 불러오는데 실패했습니다.", + documents: {}, + }; + } +} diff --git a/lib/pos/download-pos-file.ts b/lib/pos/download-pos-file.ts index d285c618..9fd6867f 100644 --- a/lib/pos/download-pos-file.ts +++ b/lib/pos/download-pos-file.ts @@ -3,20 +3,29 @@ import fs from 'fs/promises'; import path from 'path'; import type { DownloadPosFileParams, DownloadPosFileResult } from './types'; +import { DOCUMENTUM_NFS_PATH } from './types'; import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'; -const POS_BASE_PATH = '\\\\60.100.99.123\\ECM_NAS_PRM\\Download'; +// 레거시: Windows 네트워크 경로 (하위 호환성을 위해 유지) +const POS_BASE_PATH_LEGACY = '\\\\60.100.99.123\\ECM_NAS_PRM\\Download'; + +// NFS 마운트 경로 사용 +const POS_BASE_PATH = path.posix.join(DOCUMENTUM_NFS_PATH, 'Download'); /** - * POS API에서 반환된 경로의 파일을 내부망에서 읽어서 클라이언트에게 전달 - * 내부망 파일을 직접 접근할 수 없는 클라이언트를 위한 프록시 역할 + * POS API에서 반환된 경로의 파일을 NFS 마운트를 통해 읽어서 클라이언트에게 전달 + * NFS 마운트된 Documentum 저장소에서 파일을 직접 접근하여 제공 */ export async function downloadPosFile( params: DownloadPosFileParams ): Promise<DownloadPosFileResult> { try { const { relativePath } = params; - debugLog(`⬇️ POS 파일 다운로드 시작`, { relativePath, basePath: POS_BASE_PATH }); + debugLog(`⬇️ POS 파일 다운로드 시작 (NFS)`, { + relativePath, + nfsBasePath: DOCUMENTUM_NFS_PATH, + basePath: POS_BASE_PATH + }); if (!relativePath) { debugError(`❌ 파일 경로가 제공되지 않음`); @@ -26,21 +35,28 @@ export async function downloadPosFile( }; } - // Windows 경로 구분자를 정규화 - const normalizedRelativePath = relativePath.replace(/\\/g, path.sep); - debugLog(`📁 경로 정규화`, { original: relativePath, normalized: normalizedRelativePath }); + // Windows 경로 구분자를 Unix 스타일로 정규화 (NFS용) + const normalizedRelativePath = relativePath.replace(/\\/g, '/'); + debugLog(`📁 경로 정규화 (NFS)`, { + original: relativePath, + normalized: normalizedRelativePath + }); - // 전체 파일 경로 구성 - const fullPath = path.join(POS_BASE_PATH, normalizedRelativePath); - debugLog(`📍 전체 파일 경로 구성`, { fullPath }); + // NFS 마운트 경로와 상대 경로를 결합 + const fullPath = path.posix.join(POS_BASE_PATH, normalizedRelativePath); + debugLog(`📍 NFS 전체 파일 경로 구성`, { fullPath }); // 경로 보안 검증 (디렉토리 탈출 방지) const resolvedPath = path.resolve(fullPath); - const resolvedBasePath = path.resolve(POS_BASE_PATH); - debugLog(`🔒 경로 보안 검증`, { resolvedPath, resolvedBasePath, isValid: resolvedPath.startsWith(resolvedBasePath) }); + const resolvedBasePath = path.resolve(DOCUMENTUM_NFS_PATH); + debugLog(`🔒 경로 보안 검증 (NFS)`, { + resolvedPath, + resolvedBasePath, + isValid: resolvedPath.startsWith(resolvedBasePath) + }); if (!resolvedPath.startsWith(resolvedBasePath)) { - debugError(`❌ 디렉토리 탈출 시도 감지`, { resolvedPath, resolvedBasePath }); + debugError(`❌ 디렉토리 탈출 시도 감지 (NFS)`, { resolvedPath, resolvedBasePath }); return { success: false, error: '잘못된 파일 경로입니다.', @@ -48,7 +64,7 @@ export async function downloadPosFile( } // 파일 존재 여부 확인 - debugProcess(`🔍 파일 존재 여부 확인 중...`); + debugProcess(`🔍 NFS 파일 존재 여부 확인 중...`); try { await fs.access(resolvedPath); debugSuccess(`✅ 파일 존재 확인됨 (${resolvedPath})`); @@ -95,10 +111,11 @@ export async function downloadPosFile( const mimeType = await getMimeType(fileName); debugLog(`🔍 MIME 타입 추정 완료`, { fileName, mimeType }); - debugSuccess(`✅ POS 파일 다운로드 성공`, { + debugSuccess(`✅ NFS를 통한 POS 파일 다운로드 성공`, { fileName, fileSize: fileBuffer.length, - mimeType + mimeType, + nfsPath: resolvedPath }); return { @@ -108,8 +125,9 @@ export async function downloadPosFile( mimeType, }; } catch (error) { - debugError('❌ POS 파일 다운로드 실패', { - relativePath: params.relativePath, + debugError('❌ NFS를 통한 POS 파일 다운로드 실패', { + relativePath: params.relativePath, + nfsBasePath: DOCUMENTUM_NFS_PATH, error: error instanceof Error ? error.message : '알 수 없는 오류', stack: error instanceof Error ? error.stack : undefined }); diff --git a/lib/pos/get-pos.ts b/lib/pos/get-pos.ts index c24c1dab..a77de247 100644 --- a/lib/pos/get-pos.ts +++ b/lib/pos/get-pos.ts @@ -2,14 +2,23 @@ import { XMLBuilder, XMLParser } from 'fast-xml-parser'; import { withSoapLogging } from '@/lib/soap/utils'; -import type { GetEncryptDocumentumFileParams } from './types'; -import { POS_SOAP_ENDPOINT } from './types'; +import fs from 'fs/promises'; +import path from 'path'; +import type { GetEncryptDocumentumFileParams, AccessNfsFileParams, AccessNfsFileResult } from './types'; +import { POS_SOAP_ENDPOINT, DOCUMENTUM_NFS_PATH } from './types'; import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'; /** * 문서 암호화 파일을 서버에 다운로드하고 경로를 반환하는 POS(Documentum) SOAP 액션 * 반환값은 서버 내 파일 다운로드 경로입니다. (예: "asd_as_2509131735233768_OP02\asd_as_2509131735233768_OP02.tif") - * 실제 파일은 \\60.100.99.123\ECM_NAS_PRM\Download\ 경로에 저장됩니다. + * + * 실제 파일은 DOCUMENTUM_NFS 환경변수로 지정된 NFS 마운트 경로에서 접근할 수 있습니다. + * - 환경변수: DOCUMENTUM_NFS (기본값: "/mnt/nfs-documentum/") + * - 실제 파일 경로: ${DOCUMENTUM_NFS}/Download/${반환된_상대_경로} + * + * 예시: + * - SOAP 반환값: "asd_as_2509131735233768_OP02\asd_as_2509131735233768_OP02.tif" + * - 실제 NFS 파일 경로: "/mnt/nfs-documentum/Download/asd_as_2509131735233768_OP02/asd_as_2509131735233768_OP02.tif" */ export async function getEncryptDocumentumFile( params: GetEncryptDocumentumFileParams @@ -173,3 +182,226 @@ export async function getEncryptDocumentumFile( }; } } + +/** + * NFS 마운트를 통해 POS 파일에 직접 접근하는 함수 + * POS API에서 반환된 상대 경로를 사용하여 NFS 마운트된 경로에서 파일을 읽습니다 + */ +export async function accessNfsFile( + params: AccessNfsFileParams +): Promise<AccessNfsFileResult> { + try { + const { relativePath } = params; + debugLog(`📁 NFS를 통한 POS 파일 접근 시작`, { + relativePath, + nfsBasePath: DOCUMENTUM_NFS_PATH + }); + + if (!relativePath) { + debugError(`❌ 파일 경로가 제공되지 않음`); + return { + success: false, + error: '파일 경로가 제공되지 않았습니다.', + }; + } + + // Windows 경로 구분자를 Unix 스타일로 정규화 + const normalizedRelativePath = relativePath.replace(/\\/g, '/'); + debugLog(`📁 경로 정규화`, { + original: relativePath, + normalized: normalizedRelativePath + }); + + // NFS 마운트 경로와 상대 경로를 결합 + // Download 폴더를 추가하여 전체 경로 구성 + const fullPath = path.posix.join(DOCUMENTUM_NFS_PATH, 'Download', normalizedRelativePath); + debugLog(`📍 NFS 전체 파일 경로 구성`, { fullPath }); + + // 경로 보안 검증 (디렉토리 탈출 방지) + const resolvedPath = path.resolve(fullPath); + const resolvedBasePath = path.resolve(DOCUMENTUM_NFS_PATH); + debugLog(`🔒 경로 보안 검증`, { + resolvedPath, + resolvedBasePath, + isValid: resolvedPath.startsWith(resolvedBasePath) + }); + + if (!resolvedPath.startsWith(resolvedBasePath)) { + debugError(`❌ 디렉토리 탈출 시도 감지`, { resolvedPath, resolvedBasePath }); + return { + success: false, + error: '잘못된 파일 경로입니다.', + }; + } + + // 파일 존재 여부 확인 + debugProcess(`🔍 NFS 파일 존재 여부 확인 중...`); + try { + await fs.access(resolvedPath); + debugSuccess(`✅ NFS 파일 접근 가능 확인`); + } catch (accessError) { + debugError(`❌ NFS 파일 접근 불가`, { + path: resolvedPath, + error: accessError instanceof Error ? accessError.message : 'Unknown error' + }); + return { + success: false, + error: `파일을 찾을 수 없습니다: ${resolvedPath}`, + }; + } + + // 파일 정보 확인 + debugProcess(`📊 NFS 파일 정보 확인 중...`); + const stats = await fs.stat(resolvedPath); + debugLog(`📊 NFS 파일 정보`, { + size: stats.size, + isFile: stats.isFile(), + modified: stats.mtime + }); + + if (!stats.isFile()) { + debugError(`❌ 대상이 파일이 아님`, { path: resolvedPath }); + return { + success: false, + error: '대상이 파일이 아닙니다.', + }; + } + + // 파일 크기 제한 (10000MB) + const maxSize = 10000 * 1024 * 1024; // 10000MB + if (stats.size > maxSize) { + debugError(`❌ 파일 크기 초과`, { + fileSize: stats.size, + maxSize, + path: resolvedPath + }); + return { + success: false, + error: `파일 크기가 너무 큽니다 (최대 10000MB). 현재 크기: ${Math.round(stats.size / 1024 / 1024)}MB`, + }; + } + + // 파일 읽기 + debugProcess(`📖 NFS 파일 읽기 중... (크기: ${stats.size} bytes)`); + const fileBuffer = await fs.readFile(resolvedPath); + debugSuccess(`✅ NFS 파일 읽기 완료 (${fileBuffer.length} bytes)`); + + // 파일명과 MIME 타입 추출 + const fileName = path.basename(resolvedPath); + const fileExtension = path.extname(fileName).toLowerCase(); + + // 확장자별 MIME 타입 설정 + let mimeType = 'application/octet-stream'; // 기본값 + switch (fileExtension) { + case '.pdf': + mimeType = 'application/pdf'; + break; + case '.tif': + case '.tiff': + mimeType = 'image/tiff'; + break; + case '.png': + mimeType = 'image/png'; + break; + case '.jpg': + case '.jpeg': + mimeType = 'image/jpeg'; + break; + case '.doc': + mimeType = 'application/msword'; + break; + case '.docx': + mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + break; + case '.xls': + mimeType = 'application/vnd.ms-excel'; + break; + case '.xlsx': + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + break; + case '.dwg': + mimeType = 'application/acad'; + break; + case '.zip': + mimeType = 'application/zip'; + break; + } + + debugSuccess(`✅ NFS를 통한 POS 파일 접근 성공`, { + fileName, + mimeType, + fileSize: fileBuffer.length, + fullPath: resolvedPath + }); + + return { + success: true, + fileName, + fileBuffer, + mimeType, + fullPath: resolvedPath, + }; + + } catch (error) { + debugError('❌ NFS를 통한 POS 파일 접근 실패', { + relativePath: params.relativePath, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * POS 파일 접근 방법을 테스트하는 함수 + * 개발/테스트 환경에서 NFS 마운트가 제대로 작동하는지 확인하는 용도 + */ +export async function testNfsAccess(): Promise<{ + success: boolean; + nfsPath: string; + accessible: boolean; + error?: string; +}> { + try { + debugLog(`🧪 NFS 접근 테스트 시작`, { nfsPath: DOCUMENTUM_NFS_PATH }); + + // NFS 마운트 기본 경로 접근 테스트 + const testPath = path.posix.join(DOCUMENTUM_NFS_PATH, 'Download'); + debugLog(`🔍 테스트 경로 확인`, { testPath }); + + try { + await fs.access(testPath); + debugSuccess(`✅ NFS 마운트 경로 접근 성공`, { testPath }); + return { + success: true, + nfsPath: DOCUMENTUM_NFS_PATH, + accessible: true, + }; + } catch (accessError) { + debugError(`❌ NFS 마운트 경로 접근 실패`, { + testPath, + error: accessError instanceof Error ? accessError.message : 'Unknown error' + }); + return { + success: false, + nfsPath: DOCUMENTUM_NFS_PATH, + accessible: false, + error: `NFS 마운트 경로에 접근할 수 없습니다: ${testPath}`, + }; + } + } catch (error) { + debugError('❌ NFS 접근 테스트 실패', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }); + return { + success: false, + nfsPath: DOCUMENTUM_NFS_PATH, + accessible: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/lib/pos/index.ts b/lib/pos/index.ts index 7611d5c5..75309bdd 100644 --- a/lib/pos/index.ts +++ b/lib/pos/index.ts @@ -23,6 +23,13 @@ export { syncRfqPosFiles } from './sync-rfq-pos-files'; +export { + getDesignDocumentByMaterialCode, + getDesignDocumentsForRfqItems, + getDesignDocumentByMaterialCodeAction, + getDesignDocumentsForRfqItemsAction +} from './design-document-service'; + // 타입들은 ./types 에서 export export type * from './types'; diff --git a/lib/pos/types.ts b/lib/pos/types.ts index eb75c94b..2a1c6076 100644 --- a/lib/pos/types.ts +++ b/lib/pos/types.ts @@ -59,6 +59,25 @@ export interface DownloadPosFileParams { relativePath: string; } +/** + * NFS 마운트를 통한 파일 접근 파라미터 + */ +export interface AccessNfsFileParams { + /** + * POS API에서 반환된 상대 경로 + */ + relativePath: string; +} + +export interface AccessNfsFileResult { + success: boolean; + fileName?: string; + fileBuffer?: Buffer; + mimeType?: string; + fullPath?: string; + error?: string; +} + export interface DownloadPosFileResult { success: boolean; fileName?: string; @@ -85,3 +104,6 @@ export interface PosFileSyncResult { export const POS_SOAP_SEGMENT = '/Documentum/PlmFileBroker.asmx'; export const POS_SOAP_BASE_URL = process.env.POS_SOAP_ENDPOINT || 'http://60.100.99.122:7700'; export const POS_SOAP_ENDPOINT = `${POS_SOAP_BASE_URL}${POS_SOAP_SEGMENT}`; + +// NFS 마운트 관련 설정 +export const DOCUMENTUM_NFS_PATH = process.env.DOCUMENTUM_NFS || '/mnt/nfs-documentum/'; diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx index daa692e9..eb6c05b1 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { format } from "date-fns" -import { Package, ExternalLink } from "lucide-react" +import { Package, ExternalLink, Download, FileText } from "lucide-react" import { Dialog, DialogContent, @@ -26,6 +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" // 품목 타입 interface RfqItem { @@ -82,35 +84,61 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps const [items, setItems] = React.useState<RfqItem[]>([]) const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) const [isLoading, setIsLoading] = React.useState(false) + // 자재코드별 설계 문서 매핑 + const [designDocuments, setDesignDocuments] = React.useState<Record<string, { + id: number; + fileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + }>>({}) + const [isLoadingDocs, setIsLoadingDocs] = React.useState(false) - // 품목 목록 로드 + // 품목 목록 및 설계 문서 로드 React.useEffect(() => { if (!isOpen || !rfqData.id) return - const loadItems = async () => { + const loadData = async () => { setIsLoading(true) + setIsLoadingDocs(true) + try { - const result = await getRfqItemsAction(rfqData.id) + // 1. 품목 목록 로드 + const itemsResult = await getRfqItemsAction(rfqData.id) - if (result.success) { - setItems(result.data) - setStatistics(result.statistics) + if (itemsResult.success) { + setItems(itemsResult.data) + setStatistics(itemsResult.statistics || null) } else { - toast.error(result.error || "품목을 불러오는데 실패했습니다") + toast.error(itemsResult.error || "품목을 불러오는데 실패했습니다") 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("품목을 불러오는데 실패했습니다") + console.error("데이터 로드 오류:", error) + toast.error("데이터를 불러오는데 실패했습니다") setItems([]) setStatistics(null) + setDesignDocuments({}) } finally { setIsLoading(false) + setIsLoadingDocs(false) } } - loadItems() + loadData() }, [isOpen, rfqData.id]) // 사양서 링크 열기 @@ -118,6 +146,28 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps window.open(specUrl, '_blank', 'noopener,noreferrer') } + // 설계 문서 다운로드 + const handleDownloadDesignDoc = async (materialCode: string, fileName: string, filePath: string) => { + try { + await downloadFile(filePath, fileName, { + action: 'download', + showToast: true + }) + } catch (error) { + console.error("설계 문서 다운로드 오류:", error) + toast.error("설계 문서 다운로드에 실패했습니다") + } + } + + // 파일 크기 포맷팅 + 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] + } + // 수량 포맷팅 const formatQuantity = (quantity: number | null, uom: string | null) => { if (!quantity) return "-" @@ -181,7 +231,7 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[100px]">중량</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> - <TableHead className="w-[80px]">사양</TableHead> + <TableHead className="w-[120px]">사양/설계문서</TableHead> <TableHead>비고</TableHead> </TableRow> </TableHeader> @@ -217,7 +267,8 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[100px]">중량</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> - <TableHead className="w-[100px]">사양</TableHead> + <TableHead className="w-[100px]">PR 아이템 번호</TableHead> + <TableHead className="w-[120px]">사양/설계문서</TableHead> <TableHead className="w-[100px]">프로젝트</TableHead> <TableHead>비고</TableHead> </TableRow> @@ -278,36 +329,65 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps </span> </TableCell> <TableCell> - <div className="flex flex-col"> - <span className="text-xs font-mono">{item.prNo || "-"}</span> - {item.prItem && item.prItem !== item.prNo && ( - <span className="text-xs text-muted-foreground font-mono"> - {item.prItem} - </span> - )} - </div> + <span className="text-xs font-mono">{item.prNo || "-"}</span> </TableCell> <TableCell> - <div className="flex items-center gap-1"> - {item.specNo && ( - <span className="text-xs font-mono">{item.specNo}</span> - )} - {item.specUrl && ( - <Button - variant="ghost" - size="sm" - className="h-5 w-5 p-0" - onClick={() => handleOpenSpec(item.specUrl!)} - title="사양서 열기" - > - <ExternalLink className="h-3 w-3" /> - </Button> + <span className="text-xs font-mono">{item.prItem || "-"}</span> + </TableCell> + <TableCell> + <div className="flex flex-col gap-1"> + {/* 기존 스펙 정보 */} + <div className="flex items-center gap-1"> + {item.specNo && ( + <span className="text-xs font-mono">{item.specNo}</span> + )} + {item.specUrl && ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(item.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + </div> + + {/* 설계 문서 다운로드 */} + {item.materialCode && designDocuments[item.materialCode] && ( + <div className="flex items-center gap-1"> + <FileText className="h-3 w-3 text-blue-500" /> + <Button + variant="ghost" + size="sm" + className="h-5 p-1 text-xs text-blue-600 hover:text-blue-800" + onClick={() => handleDownloadDesignDoc( + item.materialCode!, + designDocuments[item.materialCode!].fileName, + designDocuments[item.materialCode!].filePath + )} + title={`설계문서: ${designDocuments[item.materialCode!].fileName} (${formatFileSize(designDocuments[item.materialCode!].fileSize)})`} + > + <Download className="h-3 w-3 mr-1" /> + 설계문서 + </Button> + </div> )} + + {/* 트래킹 번호 */} {item.trackingNo && ( <div className="text-xs text-muted-foreground"> TRK: {item.trackingNo} </div> )} + + {/* 설계 문서 로딩 상태 */} + {isLoadingDocs && item.materialCode && ( + <div className="text-xs text-muted-foreground"> + 설계문서 확인 중... + </div> + )} </div> </TableCell> <TableCell> diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts index a517d84c..620cd141 100644 --- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts @@ -151,25 +151,37 @@ export async function mapECCRfqHeaderToRfqLast( const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null); const inChargeUserId = inChargeUserInfo?.userId || null; - // 첫번째 PR Item 기반으로 projectId, itemCode, itemName 설정 + // 대표 PR Item 기반으로 projectId, itemCode, itemName 설정 (없으면 첫번째 PR Item 사용) let projectId: number | null = null; let itemCode: string | null = null; let itemName: string | null = null; let prNumber: string | null = null; - + let representativeItem: ECCBidItem | undefined; + if (firstItem) { - // projectId: 첫번째 PR Item의 PSPID와 projects.code 매칭 - const projectInfo = await findProjectInfoByPSPID(firstItem.PSPID || null); + // 대표 PR Item 찾기 (ZCON_NO_PO와 BANFN이 같은 아이템) + representativeItem = eccItems.find(item => + item.ANFNR === eccHeader.ANFNR && + item.ZCON_NO_PO && + item.ZCON_NO_PO.trim() && + item.BANFN === item.ZCON_NO_PO.trim() + ); + + // 대표 PR Item이 없으면 첫번째 PR Item 사용 + const targetItem = representativeItem || firstItem; + + // projectId: 대표 PR Item의 PSPID와 projects.code 매칭 + const projectInfo = await findProjectInfoByPSPID(targetItem.PSPID || null); projectId = projectInfo?.id || null; - - // itemCode: 첫번째 PR Item의 MATKL - itemCode = firstItem.MATKL || null; - - // itemName: 첫번째 PR Item의 MATNR로 MDG에서 ZZNAME 조회 - itemName = await findMaterialNameByMATNR(firstItem.MATNR || null); - - // prNumber: 첫번째 PR의 ZREQ_FN 값 - prNumber = firstItem.ZREQ_FN || null; + + // itemCode: 대표 PR Item의 MATNR + itemCode = targetItem.MATNR || null; + + // itemName: 대표 PR Item의 MATNR로 MDG에서 ZZNAME 조회 + itemName = await findMaterialNameByMATNR(targetItem.MATNR || null); + + // prNumber: 대표 PR의 BANFN 또는 첫번째 PR의 ZREQ_FN 값 + prNumber = representativeItem?.BANFN || firstItem.ZREQ_FN || null; } // 매핑 |
