summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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