summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/pos/design-document-service.ts188
-rw-r--r--lib/pos/download-pos-file.ts54
-rw-r--r--lib/pos/get-pos.ts238
-rw-r--r--lib/pos/index.ts7
-rw-r--r--lib/pos/types.ts22
-rw-r--r--lib/rfq-last/table/rfq-items-dialog.tsx150
-rw-r--r--lib/soap/ecc/mapper/rfq-and-pr-mapper.ts38
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;
}
// 매핑