summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-22 19:33:16 +0900
committerjoonhoekim <26rote@gmail.com>2025-09-22 19:33:16 +0900
commit480ac58010604140d1a52fa2b839aedb6ac15941 (patch)
tree4cc45c96ea174991d59c1a058ed9da05a2a3ac8c
parentba35e67845f935c8ce0151c9ef1fefa0b0510faf (diff)
(김준회) POS I/F 로직 및 UI 처리 구현
-rw-r--r--.env.development6
-rw-r--r--.env.production4
-rw-r--r--app/api/pos/download/route.ts47
-rw-r--r--lib/pos/download-pos-file.ts162
-rw-r--r--lib/pos/get-dcmtm-id.ts134
-rw-r--r--lib/pos/get-pos.ts174
-rw-r--r--lib/pos/index.ts164
-rw-r--r--lib/pos/sync-rfq-pos-files.ts407
-rw-r--r--lib/pos/types.ts87
-rw-r--r--lib/rfq-last/table/rfq-attachments-dialog.tsx80
-rw-r--r--lib/soap/ecc/send/create-po.ts8
-rw-r--r--lib/users/service.ts2
12 files changed, 1263 insertions, 12 deletions
diff --git a/.env.development b/.env.development
index f4017238..603261b2 100644
--- a/.env.development
+++ b/.env.development
@@ -177,4 +177,8 @@ READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" #
# === 디버그 로깅 (lib/debug-utils.ts) ===
NEXT_PUBLIC_DEBUG=true
-SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc \ No newline at end of file
+SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc
+
+# POS (EMLS, Documentum)
+ECC_PO_ENDPOINT="http://60.100.99.122:7700" # 품질
+POS_APP_CODE="SQ13" # 품질, 운영의 경우 SO13 \ No newline at end of file
diff --git a/.env.production b/.env.production
index 5214c212..ed881b6f 100644
--- a/.env.production
+++ b/.env.production
@@ -180,3 +180,7 @@ READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" #
NEXT_PUBLIC_DEBUG=false
SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc
+
+# POS (EMLS, Documentum)
+ECC_PO_ENDPOINT="http://60.100.99.122:7700" # 품질
+POS_APP_CODE="SQ13" # 품질, 운영의 경우 SO13 \ No newline at end of file
diff --git a/app/api/pos/download/route.ts b/app/api/pos/download/route.ts
new file mode 100644
index 00000000..5328dc9d
--- /dev/null
+++ b/app/api/pos/download/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { downloadPosFile } from '@/lib/pos/download-pos-file';
+
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams;
+ const relativePath = searchParams.get('path');
+
+ if (!relativePath) {
+ return NextResponse.json(
+ { error: '파일 경로가 제공되지 않았습니다.' },
+ { status: 400 }
+ );
+ }
+
+ const result = await downloadPosFile({ relativePath });
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error },
+ { status: 404 }
+ );
+ }
+
+ if (!result.fileBuffer || !result.fileName) {
+ return NextResponse.json(
+ { error: '파일을 읽을 수 없습니다.' },
+ { status: 500 }
+ );
+ }
+
+ // 파일 다운로드 응답 생성
+ const response = new NextResponse(result.fileBuffer);
+
+ response.headers.set('Content-Type', result.mimeType || 'application/octet-stream');
+ response.headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`);
+ response.headers.set('Content-Length', result.fileBuffer.length.toString());
+
+ return response;
+ } catch (error) {
+ console.error('POS 파일 다운로드 API 오류:', error);
+ return NextResponse.json(
+ { error: '서버 내부 오류가 발생했습니다.' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/lib/pos/download-pos-file.ts b/lib/pos/download-pos-file.ts
new file mode 100644
index 00000000..d285c618
--- /dev/null
+++ b/lib/pos/download-pos-file.ts
@@ -0,0 +1,162 @@
+'use server';
+
+import fs from 'fs/promises';
+import path from 'path';
+import type { DownloadPosFileParams, DownloadPosFileResult } from './types';
+import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils';
+
+const POS_BASE_PATH = '\\\\60.100.99.123\\ECM_NAS_PRM\\Download';
+
+/**
+ * POS API에서 반환된 경로의 파일을 내부망에서 읽어서 클라이언트에게 전달
+ * 내부망 파일을 직접 접근할 수 없는 클라이언트를 위한 프록시 역할
+ */
+export async function downloadPosFile(
+ params: DownloadPosFileParams
+): Promise<DownloadPosFileResult> {
+ try {
+ const { relativePath } = params;
+ debugLog(`⬇️ POS 파일 다운로드 시작`, { relativePath, basePath: POS_BASE_PATH });
+
+ if (!relativePath) {
+ debugError(`❌ 파일 경로가 제공되지 않음`);
+ return {
+ success: false,
+ error: '파일 경로가 제공되지 않았습니다.',
+ };
+ }
+
+ // Windows 경로 구분자를 정규화
+ const normalizedRelativePath = relativePath.replace(/\\/g, path.sep);
+ debugLog(`📁 경로 정규화`, { original: relativePath, normalized: normalizedRelativePath });
+
+ // 전체 파일 경로 구성
+ const fullPath = path.join(POS_BASE_PATH, normalizedRelativePath);
+ debugLog(`📍 전체 파일 경로 구성`, { fullPath });
+
+ // 경로 보안 검증 (디렉토리 탈출 방지)
+ const resolvedPath = path.resolve(fullPath);
+ const resolvedBasePath = path.resolve(POS_BASE_PATH);
+ debugLog(`🔒 경로 보안 검증`, { resolvedPath, resolvedBasePath, isValid: resolvedPath.startsWith(resolvedBasePath) });
+
+ if (!resolvedPath.startsWith(resolvedBasePath)) {
+ debugError(`❌ 디렉토리 탈출 시도 감지`, { resolvedPath, resolvedBasePath });
+ return {
+ success: false,
+ error: '잘못된 파일 경로입니다.',
+ };
+ }
+
+ // 파일 존재 여부 확인
+ debugProcess(`🔍 파일 존재 여부 확인 중...`);
+ try {
+ await fs.access(resolvedPath);
+ debugSuccess(`✅ 파일 존재 확인됨 (${resolvedPath})`);
+ } catch (accessError) {
+ debugError(`❌ 파일을 찾을 수 없음`, { resolvedPath, error: accessError });
+ return {
+ success: false,
+ error: '파일을 찾을 수 없습니다.',
+ };
+ }
+
+ // 파일 정보 가져오기
+ debugProcess(`📊 파일 정보 조회 중...`);
+ const stats = await fs.stat(resolvedPath);
+ debugLog(`📊 파일 정보`, {
+ resolvedPath,
+ size: stats.size,
+ isFile: stats.isFile(),
+ isDirectory: stats.isDirectory(),
+ mtime: stats.mtime
+ });
+
+ if (!stats.isFile()) {
+ debugError(`❌ 대상이 파일이 아님 (디렉토리)`, { resolvedPath });
+ return {
+ success: false,
+ error: '디렉토리는 다운로드할 수 없습니다.',
+ };
+ }
+
+ // 파일 읽기
+ debugProcess(`📖 파일 읽기 시작 (크기: ${stats.size} bytes)...`);
+ const fileBuffer = await fs.readFile(resolvedPath);
+ const fileName = path.basename(resolvedPath);
+ debugLog(`📖 파일 읽기 완료`, {
+ fileName,
+ bufferSize: fileBuffer.length,
+ expectedSize: stats.size,
+ sizeMatch: fileBuffer.length === stats.size
+ });
+
+ // MIME 타입 추정
+ debugProcess(`🔍 MIME 타입 추정 중...`);
+ const mimeType = await getMimeType(fileName);
+ debugLog(`🔍 MIME 타입 추정 완료`, { fileName, mimeType });
+
+ debugSuccess(`✅ POS 파일 다운로드 성공`, {
+ fileName,
+ fileSize: fileBuffer.length,
+ mimeType
+ });
+
+ return {
+ success: true,
+ fileName,
+ fileBuffer,
+ mimeType,
+ };
+ } catch (error) {
+ debugError('❌ POS 파일 다운로드 실패', {
+ relativePath: params.relativePath,
+ error: error instanceof Error ? error.message : '알 수 없는 오류',
+ stack: error instanceof Error ? error.stack : undefined
+ });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
+ };
+ }
+}
+
+/**
+ * 파일 확장자를 기반으로 MIME 타입 추정
+ */
+export async function getMimeType(fileName: string): Promise<string> {
+ const ext = path.extname(fileName).toLowerCase();
+
+ const mimeTypes: Record<string, string> = {
+ '.pdf': 'application/pdf',
+ '.tif': 'image/tiff',
+ '.tiff': 'image/tiff',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.bmp': 'image/bmp',
+ '.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',
+ '.dwg': 'application/acad',
+ '.dxf': 'application/dxf',
+ '.zip': 'application/zip',
+ '.rar': 'application/x-rar-compressed',
+ '.7z': 'application/x-7z-compressed',
+ '.txt': 'text/plain',
+ };
+
+ return mimeTypes[ext] || 'application/octet-stream';
+}
+
+/**
+ * 클라이언트에서 사용할 수 있는 다운로드 URL을 생성하는 헬퍼 함수
+ * 실제 다운로드는 별도의 API 엔드포인트에서 처리
+ */
+export async function createDownloadUrl(relativePath: string): Promise<string> {
+ const encodedPath = encodeURIComponent(relativePath);
+ return `/api/pos/download?path=${encodedPath}`;
+}
diff --git a/lib/pos/get-dcmtm-id.ts b/lib/pos/get-dcmtm-id.ts
new file mode 100644
index 00000000..353c5b1d
--- /dev/null
+++ b/lib/pos/get-dcmtm-id.ts
@@ -0,0 +1,134 @@
+'use server';
+
+import { oracleKnex } from '@/lib/oracle-db/db';
+import type { PosFileInfo, GetDcmtmIdParams, GetDcmtmIdResult } from './types';
+import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils';
+
+/**
+ * 자재코드로 POS 파일의 DCMTM_ID를 조회하는 서버 액션
+ *
+ * @param params - 자재코드를 포함한 조회 파라미터
+ * @returns POS 파일 정보 목록 (여러 파일이 있을 수 있음)
+ */
+export async function getDcmtmIdByMaterialCode(
+ params: GetDcmtmIdParams
+): Promise<GetDcmtmIdResult> {
+ try {
+ const { materialCode } = params;
+ debugLog(`🔍 DCMTM_ID 조회 시작`, { materialCode });
+
+ if (!materialCode) {
+ debugError(`❌ 자재코드가 제공되지 않음`);
+ return {
+ success: false,
+ error: '자재코드가 제공되지 않았습니다.',
+ };
+ }
+
+ // Oracle 쿼리 실행
+ const query = `
+ SELECT Y.PROJ_NO,
+ Y.POS_NO,
+ Y.POS_REV_NO,
+ Y.FILE_SER,
+ Y.FILE_NM,
+ Y.DCMTM_ID
+ FROM PLDTB_POS_ITEM X,
+ PLDTB_POS_ITEM_FILE Y,
+ PLMTB_EMLS Z
+ WHERE X.PROJ_NO = Y.PROJ_NO
+ AND X.POS_NO = Y.POS_NO
+ AND X.POS_REV_NO = Y.POS_REV_NO
+ AND X.POS_NO = Z.DOC_NO
+ AND NVL(X.APP_ID, ' ') <> ' '
+ AND NVL(Z.PUR_REQ_NO, ' ') <> ' '
+ AND Z.MAT_NO = ?
+ `;
+
+ debugProcess(`🗄️ Oracle 쿼리 실행`, { materialCode, query: query.trim() });
+ const results = await oracleKnex.raw(query, [materialCode]);
+ debugLog(`🗄️ Oracle 쿼리 결과`, { materialCode, resultType: typeof results, hasRows: results && results[0] ? results[0].length : 0 });
+
+ // Oracle 결과 처리 (oracledb 드라이버의 결과 구조에 따라)
+ const rows = results[0] || results.rows || results;
+ debugLog(`📊 Oracle 결과 구조 분석`, {
+ materialCode,
+ resultStructure: {
+ hasZeroIndex: !!results[0],
+ hasRowsProperty: !!results.rows,
+ rowsType: typeof rows,
+ isArray: Array.isArray(rows),
+ rowCount: Array.isArray(rows) ? rows.length : 0
+ },
+ firstRow: Array.isArray(rows) && rows.length > 0 ? rows[0] : null
+ });
+
+ if (!Array.isArray(rows) || rows.length === 0) {
+ debugLog(`⚠️ POS 파일을 찾을 수 없음 (자재코드: ${materialCode})`);
+ return {
+ success: false,
+ error: '해당 자재코드에 대한 POS 파일을 찾을 수 없습니다.',
+ };
+ }
+
+ // 결과를 PosFileInfo 형태로 변환
+ debugProcess(`🔄 Oracle 결과를 PosFileInfo로 변환 중...`);
+ const files: PosFileInfo[] = rows.map((row: Record<string, unknown>, index: number) => {
+ const fileInfo = {
+ projNo: (row.PROJ_NO || row[0]) as string,
+ posNo: (row.POS_NO || row[1]) as string,
+ posRevNo: (row.POS_REV_NO || row[2]) as string,
+ fileSer: (row.FILE_SER || row[3]) as string,
+ fileName: (row.FILE_NM || row[4]) as string,
+ dcmtmId: (row.DCMTM_ID || row[5]) as string,
+ };
+ debugLog(`📄 파일 ${index + 1} 변환 결과`, { materialCode, index, original: row, converted: fileInfo });
+ return fileInfo;
+ });
+
+ debugSuccess(`✅ DCMTM_ID 조회 성공 (자재코드: ${materialCode}, 파일 수: ${files.length})`);
+ return {
+ success: true,
+ files,
+ };
+ } catch (error) {
+ debugError('❌ DCMTM_ID 조회 실패', {
+ materialCode: params.materialCode,
+ error: error instanceof Error ? error.message : '알 수 없는 오류',
+ stack: error instanceof Error ? error.stack : undefined
+ });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '데이터베이스 조회 중 오류가 발생했습니다.',
+ };
+ }
+}
+
+/**
+ * 단일 DCMTM_ID만 필요한 경우를 위한 헬퍼 함수
+ * 여러 파일이 있을 경우 첫 번째 파일의 DCMTM_ID를 반환
+ */
+export async function getFirstDcmtmId(
+ params: GetDcmtmIdParams
+): Promise<{
+ success: boolean;
+ dcmtmId?: string;
+ fileName?: string;
+ error?: string;
+}> {
+ const result = await getDcmtmIdByMaterialCode(params);
+
+ if (!result.success || !result.files || result.files.length === 0) {
+ return {
+ success: false,
+ error: result.error,
+ };
+ }
+
+ const firstFile = result.files[0];
+ return {
+ success: true,
+ dcmtmId: firstFile.dcmtmId,
+ fileName: firstFile.fileName,
+ };
+}
diff --git a/lib/pos/get-pos.ts b/lib/pos/get-pos.ts
new file mode 100644
index 00000000..6424b880
--- /dev/null
+++ b/lib/pos/get-pos.ts
@@ -0,0 +1,174 @@
+'use server';
+
+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 { 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\ 경로에 저장됩니다.
+ */
+export async function getEncryptDocumentumFile(
+ params: GetEncryptDocumentumFileParams
+): Promise<{
+ success: boolean;
+ result?: string;
+ error?: string;
+}> {
+ try {
+ const {
+ objectID,
+ sabun = 'EVM0236', // context2.txt에 따라 기본값 설정
+ appCode = process.env.POS_APP_CODE || 'SO13', // 환경변수 사용
+ fileCreateMode = 1, // context2.txt에 따라 기본값 변경
+ securityLevel = 'SedamsClassID',
+ isDesign = true, // context2.txt에 따라 기본값 변경
+ } = params;
+
+ debugLog(`🌐 POS SOAP API 호출 시작`, {
+ objectID,
+ sabun,
+ appCode,
+ fileCreateMode,
+ securityLevel,
+ isDesign,
+ endpoint: POS_SOAP_ENDPOINT
+ });
+
+ // 1. SOAP Envelope 생성 (SOAP 1.2)
+ debugProcess(`📄 SOAP Envelope 생성 중...`);
+ const envelopeObj = {
+ 'soap12:Envelope': {
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
+ '@_xmlns:xsd': 'http://www.w3.org/2001/XMLSchema',
+ '@_xmlns:soap12': 'http://www.w3.org/2003/05/soap-envelope',
+ 'soap12:Body': {
+ GetEncryptDocumentumFile: {
+ '@_xmlns': 'Sedams.WebServices.Documentum',
+ objectID,
+ sabun,
+ appCode,
+ fileCreateMode: String(fileCreateMode),
+ securityLevel,
+ isDesign: String(isDesign),
+ },
+ },
+ },
+ };
+
+ const builder = new XMLBuilder({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ format: false,
+ suppressEmptyNode: true,
+ });
+
+ const xmlBody = builder.build(envelopeObj);
+ debugLog(`📄 생성된 SOAP XML`, {
+ objectID,
+ xmlLength: xmlBody.length,
+ envelope: envelopeObj['soap12:Envelope']['soap12:Body']
+ });
+
+ // 2. SOAP 호출 (로그 포함)
+ debugProcess(`🌐 SOAP 요청 전송 중... (${POS_SOAP_ENDPOINT})`);
+ const responseText = await withSoapLogging(
+ 'OUTBOUND',
+ 'SEDAMS Documentum',
+ 'GetEncryptDocumentumFile',
+ xmlBody,
+ async () => {
+ const res = await fetch(POS_SOAP_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'text/xml; charset=utf-8',
+ SOAPAction:
+ '"Sedams.WebServices.Documentum/GetEncryptDocumentumFile"',
+ },
+ body: xmlBody,
+ });
+
+ const text = await res.text();
+ debugLog(`🌐 HTTP 응답 수신`, {
+ objectID,
+ status: res.status,
+ statusText: res.statusText,
+ responseLength: text.length,
+ headers: Object.fromEntries(res.headers.entries())
+ });
+
+ if (!res.ok) {
+ debugError(`❌ HTTP 오류 응답`, { objectID, status: res.status, statusText: res.statusText, responseBody: text });
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
+ }
+
+ return text;
+ }
+ );
+
+ debugSuccess(`✅ SOAP 응답 수신 완료 (응답 길이: ${responseText.length})`);
+
+ // 3. 응답 XML 파싱
+ debugProcess(`📝 XML 응답 파싱 중...`);
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ });
+
+ const parsed = parser.parse(responseText);
+ debugLog(`📝 파싱된 XML 구조`, {
+ objectID,
+ parsedKeys: Object.keys(parsed),
+ parsed: parsed
+ });
+
+ let result: string | undefined;
+
+ try {
+ // SOAP 1.2 규격
+ debugProcess(`🔍 SOAP 1.2 구조에서 결과 추출 시도...`);
+ result =
+ parsed['soap:Envelope']['soap:Body'][
+ 'GetEncryptDocumentumFileResponse'
+ ]['GetEncryptDocumentumFileResult'];
+ debugLog(`🎯 SOAP 1.2에서 결과 추출 성공`, { objectID, result });
+ } catch (e1) {
+ debugLog(`⚠️ SOAP 1.2 구조 추출 실패, Fallback 시도...`, { objectID, error: e1 });
+ // Fallback: SOAP 1.1 또는 다른 응답 형태
+ try {
+ debugProcess(`🔍 SOAP 1.1 구조에서 결과 추출 시도...`);
+ result =
+ parsed['soap:Envelope']['soap:Body'][
+ 'GetEncryptDocumentumFileResponse'
+ ]['GetEncryptDocumentumFileResult'];
+ debugLog(`🎯 SOAP 1.1에서 결과 추출 성공`, { objectID, result });
+ } catch (e2) {
+ debugLog(`⚠️ SOAP 1.1 구조 추출 실패, 단순 string 시도...`, { objectID, error: e2 });
+ // HTTP POST 응답 형태 (단순 string)
+ try {
+ debugProcess(`🔍 단순 string 구조에서 결과 추출 시도...`);
+ result = parsed.string;
+ debugLog(`🎯 단순 string에서 결과 추출 성공`, { objectID, result });
+ } catch (e3) {
+ debugError(`❌ 모든 파싱 방법 실패`, { objectID, errors: [e1, e2, e3], parsedStructure: parsed });
+ }
+ }
+ }
+
+ debugSuccess(`✅ POS API 호출 성공`, { objectID, result });
+ return { success: true, result };
+ } catch (error) {
+ debugError('❌ POS SOAP 호출 실패', {
+ objectID: params.objectID,
+ 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',
+ };
+ }
+}
diff --git a/lib/pos/index.ts b/lib/pos/index.ts
new file mode 100644
index 00000000..7611d5c5
--- /dev/null
+++ b/lib/pos/index.ts
@@ -0,0 +1,164 @@
+// POS 관련 모든 기능을 하나로 통합하는 인덱스 파일
+
+import { getEncryptDocumentumFile } from './get-pos';
+import { createDownloadUrl } from './download-pos-file';
+import { getDcmtmIdByMaterialCode } from './get-dcmtm-id';
+import type { PosFileInfo } from './types';
+
+export {
+ getEncryptDocumentumFile
+} from './get-pos';
+
+export {
+ downloadPosFile,
+ createDownloadUrl
+} from './download-pos-file';
+
+export {
+ getDcmtmIdByMaterialCode,
+ getFirstDcmtmId
+} from './get-dcmtm-id';
+
+export {
+ syncRfqPosFiles
+} from './sync-rfq-pos-files';
+
+// 타입들은 ./types 에서 export
+export type * from './types';
+
+/**
+ * POS 파일을 가져와서 다운로드 URL을 생성하는 통합 함수
+ *
+ * @example
+ * ```typescript
+ * const result = await getPosFileAndCreateDownloadUrl({
+ * objectID: "0900746983f2e12a"
+ * });
+ *
+ * if (result.success && result.downloadUrl) {
+ * // 클라이언트에서 다운로드 링크 사용
+ * window.open(result.downloadUrl, '_blank');
+ * }
+ * ```
+ */
+export async function getPosFileAndCreateDownloadUrl(params: {
+ objectID: string;
+ sabun?: string;
+ appCode?: string;
+ fileCreateMode?: number;
+ securityLevel?: string;
+ isDesign?: boolean;
+}): Promise<{
+ success: boolean;
+ downloadUrl?: string;
+ fileName?: string;
+ error?: string;
+}> {
+ try {
+ // 1. POS에서 파일 경로 가져오기
+ const posResult = await getEncryptDocumentumFile(params);
+
+ if (!posResult.success || !posResult.result) {
+ return {
+ success: false,
+ error: posResult.error || 'POS에서 파일 정보를 가져올 수 없습니다.',
+ };
+ }
+
+ // 2. 다운로드 URL 생성
+ const downloadUrl = await createDownloadUrl(posResult.result);
+
+ // 파일명 추출 (경로의 마지막 부분)
+ const fileName = posResult.result.split(/[\\\/]/).pop() || 'unknown';
+
+ return {
+ success: true,
+ downloadUrl,
+ fileName,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
+
+/**
+ * 자재코드부터 POS 파일 다운로드까지 전체 워크플로우를 처리하는 통합 함수
+ *
+ * @example
+ * ```typescript
+ * const result = await getPosFileByMaterialCode({
+ * materialCode: "SN2693A6410100001"
+ * });
+ *
+ * if (result.success && result.downloadUrl) {
+ * // 클라이언트에서 다운로드 링크 사용
+ * window.open(result.downloadUrl, '_blank');
+ * }
+ * ```
+ */
+export async function getPosFileByMaterialCode(params: {
+ materialCode: string;
+ /**
+ * 여러 파일이 있을 경우 선택할 파일 인덱스 (기본값: 0)
+ */
+ fileIndex?: number;
+}): Promise<{
+ success: boolean;
+ downloadUrl?: string;
+ fileName?: string;
+ availableFiles?: PosFileInfo[];
+ error?: string;
+}> {
+ try {
+ const { materialCode, fileIndex = 0 } = params;
+
+ // 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,
+ };
+ }
+
+ const selectedFile = dcmtmResult.files[fileIndex];
+
+ // 2. DCMTM_ID로 POS 파일 정보 가져오기
+ const posResult = await getPosFileAndCreateDownloadUrl({
+ objectID: selectedFile.dcmtmId,
+ });
+
+ if (!posResult.success) {
+ return {
+ success: false,
+ error: posResult.error,
+ availableFiles: dcmtmResult.files,
+ };
+ }
+
+ return {
+ success: true,
+ downloadUrl: posResult.downloadUrl,
+ fileName: selectedFile.fileName, // 오라클에서 가져온 파일명 사용
+ availableFiles: dcmtmResult.files,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+}
diff --git a/lib/pos/sync-rfq-pos-files.ts b/lib/pos/sync-rfq-pos-files.ts
new file mode 100644
index 00000000..acb34f20
--- /dev/null
+++ b/lib/pos/sync-rfq-pos-files.ts
@@ -0,0 +1,407 @@
+'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<PosFileSyncResult> {
+ 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/pos/types.ts b/lib/pos/types.ts
new file mode 100644
index 00000000..eb75c94b
--- /dev/null
+++ b/lib/pos/types.ts
@@ -0,0 +1,87 @@
+// POS 관련 타입 정의
+
+export interface PosFileInfo {
+ projNo: string;
+ posNo: string;
+ posRevNo: string;
+ fileSer: string;
+ fileName: string;
+ dcmtmId: string;
+}
+
+export interface GetDcmtmIdParams {
+ /**
+ * 자재코드 (MATNR)
+ * 예: 'SN2693A6410100001'
+ */
+ materialCode: string;
+}
+
+export interface GetDcmtmIdResult {
+ success: boolean;
+ files?: PosFileInfo[];
+ error?: string;
+}
+
+export interface GetEncryptDocumentumFileParams {
+ objectID: string;
+ /**
+ * 사번. 기본값: 'EVM0236' (context2.txt 기준)
+ */
+ sabun?: string;
+ /**
+ * 앱 코드. 기본값: 환경변수 POS_APP_CODE 또는 'SO13'(품질)
+ */
+ appCode?: string;
+ /**
+ * 파일 생성 모드. 기본값: 1 (context2.txt 기준)
+ * 0: PDF Rendition 우선, 실패시 원본
+ * 1: 원본 파일
+ * 2: PDF 파일
+ * 3: TIFF 파일
+ */
+ fileCreateMode?: number;
+ /**
+ * 보안 레벨. 기본값: 'SedamsClassID'
+ */
+ securityLevel?: string;
+ /**
+ * 설계파일 여부. 기본값: true (context2.txt 기준)
+ */
+ isDesign?: boolean;
+}
+
+export interface DownloadPosFileParams {
+ /**
+ * POS API에서 반환된 상대 경로
+ * 예: "asd_as_2509131735233768_OP02\asd_as_2509131735233768_OP02.tif"
+ */
+ relativePath: string;
+}
+
+export interface DownloadPosFileResult {
+ success: boolean;
+ fileName?: string;
+ fileBuffer?: Buffer;
+ mimeType?: string;
+ error?: string;
+}
+
+export interface PosFileSyncResult {
+ success: boolean;
+ processedCount: number;
+ successCount: number;
+ failedCount: number;
+ errors: string[];
+ details: {
+ materialCode: string;
+ fileName?: string;
+ status: 'success' | 'failed' | 'no_files';
+ error?: string;
+ }[];
+}
+
+// POS SOAP 엔드포인트 정보
+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}`;
diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx
index 05747c6c..4513b0b0 100644
--- a/lib/rfq-last/table/rfq-attachments-dialog.tsx
+++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { format } from "date-fns"
-import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react"
+import { Download, FileText, Eye, ExternalLink, Loader2, RefreshCw } from "lucide-react"
import {
Dialog,
DialogContent,
@@ -26,6 +26,8 @@ import { toast } from "sonner"
import { RfqsLastView } from "@/db/schema"
import { getRfqAttachmentsAction } from "../service"
import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download"
+import { syncRfqPosFiles } from "@/lib/pos"
+import { useSession } from "next-auth/react"
// 첨부파일 타입
interface RfqAttachment {
@@ -55,6 +57,9 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment
const [attachments, setAttachments] = React.useState<RfqAttachment[]>([])
const [isLoading, setIsLoading] = React.useState(false)
const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set())
+ const [isSyncing, setIsSyncing] = React.useState(false)
+
+ const { data: session } = useSession()
// 첨부파일 목록 로드
React.useEffect(() => {
@@ -153,6 +158,49 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment
}
}
+ // POS 파일 동기화 핸들러
+ const handlePosSync = async () => {
+ if (!session?.user?.id || !rfqData.id) {
+ toast.error("로그인이 필요하거나 RFQ 정보가 없습니다")
+ return
+ }
+
+ setIsSyncing(true)
+
+ try {
+ const result = await syncRfqPosFiles(rfqData.id, parseInt(session.user.id))
+
+ if (result.success) {
+ toast.success(
+ `POS 파일 동기화 완료: 성공 ${result.successCount}건, 실패 ${result.failedCount}건`
+ )
+
+ // 성공한 경우 첨부파일 목록 새로고침
+ if (result.successCount > 0) {
+ const refreshResult = await getRfqAttachmentsAction(rfqData.id)
+ if (refreshResult.success) {
+ setAttachments(refreshResult.data)
+ }
+ }
+
+ // 상세 결과 표시
+ if (result.details.length > 0) {
+ const failedItems = result.details.filter(d => d.status === 'failed')
+ if (failedItems.length > 0) {
+ console.warn("POS 동기화 실패 항목:", failedItems)
+ }
+ }
+ } else {
+ toast.error(`POS 파일 동기화 실패: ${result.errors.join(', ')}`)
+ }
+ } catch (error) {
+ console.error("POS 동기화 오류:", error)
+ toast.error("POS 파일 동기화 중 오류가 발생했습니다")
+ } finally {
+ setIsSyncing(false)
+ }
+ }
+
// 첨부파일 타입별 색상
const getAttachmentTypeBadgeVariant = (type: string) => {
switch (type.toLowerCase()) {
@@ -167,11 +215,31 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-6xl h-[85vh] flex flex-col">
<DialogHeader>
- <DialogTitle>견적 첨부파일</DialogTitle>
- <DialogDescription>
- {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"}
- {attachments.length > 0 && ` (${attachments.length}개 파일)`}
- </DialogDescription>
+ <div className="flex items-center justify-between">
+ <div className="flex-1">
+ <DialogTitle>견적 첨부파일</DialogTitle>
+ <DialogDescription>
+ {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"}
+ {attachments.length > 0 && ` (${attachments.length}개 파일)`}
+ </DialogDescription>
+ </div>
+
+ {/* POS 동기화 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handlePosSync}
+ disabled={isSyncing || isLoading}
+ className="flex items-center gap-2"
+ >
+ {isSyncing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <RefreshCw className="h-4 w-4" />
+ )}
+ {isSyncing ? "동기화 중..." : "POS 파일 동기화"}
+ </Button>
+ </div>
</DialogHeader>
<ScrollArea className="flex-1">
diff --git a/lib/soap/ecc/send/create-po.ts b/lib/soap/ecc/send/create-po.ts
index 444d9cc1..47894d50 100644
--- a/lib/soap/ecc/send/create-po.ts
+++ b/lib/soap/ecc/send/create-po.ts
@@ -8,14 +8,14 @@ const ECC_PO_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/
// PO 헤더 데이터 타입
export interface POHeaderData {
- ANFNR: string; // Bidding Number (M)
+ ANFNR: string; // Bidding Number (M) // BiddingNumber이지만, RFQ/Bidding 모두 받은 ANFNR 사용하면 됩니다. (RFQ/Bidding Header Key)
LIFNR: string; // Vendor Account Number (M)
ZPROC_IND: string; // Purchasing Processing State (M)
- ANGNR?: string; // Bidding Number
+ ANGNR?: string; // Bidding Number // 더 이상 사용하지 않음. 보내지 않으면 됨.
WAERS: string; // Currency Key (M)
ZTERM: string; // Terms of Payment Key (M)
- INCO1: string; // Incoterms (Part 1) (M)
- INCO2: string; // Incoterms (Part 2) (M)
+ INCO1: string; // Incoterms (Part 1) (M) //인코텀즈코드 3자리
+ INCO2: string; // Incoterms (Part 2) (M) //자유입력28자리, 인코텀즈 풀네임 28자리로 잘라 넣으면 될 듯... 데이터예시: 시방서에 따름
VSTEL?: string; // Shipping Point
LSTEL?: string; // loading Point
MWSKZ: string; // Tax on sales/purchases code (M)
diff --git a/lib/users/service.ts b/lib/users/service.ts
index 7019578f..96bc4719 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -14,7 +14,7 @@ import { getErrorMessage } from "@/lib/handle-error";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { and, or, desc, asc, ilike, eq, isNull, isNotNull, sql, count, inArray, ne, not } from "drizzle-orm";
-import { SaveFileResult, saveFile } from '../file-stroage';
+import { SaveFileResult, saveFile } from '@/lib/file-stroage';
interface AssignUsersArgs {
roleId: number