diff options
| -rw-r--r-- | db/schema/rfqLast.ts | 13 | ||||
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 209 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/common-mapper-utils.ts | 51 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/rfq-and-pr-mapper.ts | 17 | ||||
| -rw-r--r-- | lib/soap/ecc/send/create-po.ts | 49 | ||||
| -rw-r--r-- | lib/soap/sender.ts | 103 | ||||
| -rw-r--r-- | lib/soap/types.ts | 64 | ||||
| -rw-r--r-- | lib/soap/utils.ts | 26 | ||||
| -rw-r--r-- | lib/swp/table/swp-document-detail-dialog.tsx | 176 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-table.tsx | 20 | ||||
| -rw-r--r-- | lib/vendor-document-list/dolce-upload-service.ts | 184 |
11 files changed, 605 insertions, 307 deletions
diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts index 7374538f..7ab03c30 100644 --- a/db/schema/rfqLast.ts +++ b/db/schema/rfqLast.ts @@ -54,6 +54,7 @@ export const rfqsLast = pgTable( rfqSealedYn: boolean("rfq_sealed_yn").default(false), EngPicName: varchar("eng_pic_name", { length: 50 }), + EngPicEmployeeNumber: varchar("eng_pic_employee_number", { length: 50 }), packageNo: varchar("package_no", { length: 50 }), packageName: varchar("package_name", { length: 255 }), @@ -322,6 +323,17 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { projectId: sql<number | null>`${rfqsLast.projectId}`.as("project_id"), projectCode: sql<string | null>`${projects.code}`.as("project_code"), projectName: sql<string | null>`${projects.name}`.as("project_name"), + + // Classification (선급) + classNo: sql<string | null>` + CASE + WHEN ${projects.CLS_1} IS NOT NULL AND ${projects.CLS_2} IS NOT NULL + THEN CONCAT(${projects.CLS_1}, '-', ${projects.CLS_2}) + WHEN ${projects.CLS_1} IS NOT NULL + THEN ${projects.CLS_1} + ELSE NULL + END + `.as("class_no"), // Item information itemCode: sql<string | null>`${rfqsLast.itemCode}`.as("item_code"), @@ -333,6 +345,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { // Engineering PIC engPicName: sql<string | null>`${rfqsLast.EngPicName}`.as("eng_pic_name"), + engPicEmployeeNumber: sql<string | null>`${rfqsLast.EngPicEmployeeNumber}`.as("eng_pic_employee_number"), // Status and dates status: sql<string>`${rfqsLast.status}`.as("status"), diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 26b50c3c..a4be7e48 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -13,6 +13,7 @@ import { generateContractNumber } from "../general-contracts/service"; import { generateBiddingNumber } from "../bidding/service"; import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po"; import { getCurrentSAPDate } from "@/lib/soap/utils"; +import { SoapResponseError } from "@/lib/soap/types"; // ===== PO (SAP) 생성 ===== interface CreatePOParams { @@ -111,73 +112,121 @@ export async function createPO(params: CreatePOParams) { quotationItems.map(item => [item.rfqPrItemId, item]) ); - // 4. 필수 필드 검증 (경고만 출력, 전송은 계속 진행) + // 4. 필수 필드 설정 - 벤더 견적 응답 조건 우선, 구매자 제시 조건을 fallback으로 사용 + // 우선순위: vendorResponse (벤더 제출 조건) > detailData (구매자 제시 조건) + + const validationErrors: string[] = []; + + // 헤더 필수 필드 검증 및 설정 + // 1. LIFNR - 벤더 코드 if (!vendorData.vendorCode) { - console.warn(`⚠️ 벤더 코드가 없습니다. (Vendor ID: ${vendorData.id}) - 빈 값으로 전송합니다.`); + validationErrors.push(`❌ 벤더 코드 (Vendor ID: ${vendorData.id})`); } - const vendorCode = vendorData.vendorCode || ''; // 빈 값으로 기본값 설정 + const vendorCode = vendorData.vendorCode || ''; - if (!detailData.paymentTermsCode) { - console.warn("⚠️ 지급조건(Payment Terms)이 설정되지 않았습니다. - 빈 값으로 전송합니다."); + // 2. ANFNR - RFQ 번호 + const anfnr = rfqData.ANFNR || rfqData.rfqCode; + if (!anfnr) { + validationErrors.push(`❌ RFQ 번호 (ANFNR 또는 rfqCode)`); } - const paymentTermsCode = detailData.paymentTermsCode || ''; // 빈 값으로 기본값 설정 - if (!detailData.incotermsCode) { - console.warn("⚠️ 인코텀즈(Incoterms)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); + // 3. WAERS - 통화 (벤더 견적 통화 우선) + const currency = vendorResponse.vendorCurrency || vendorResponse.currency || detailData.currency || params.currency || ''; + if (!currency) { + validationErrors.push(`❌ 통화(Currency) - 벤더 견적 또는 RFQ 조건에 통화가 설정되지 않았습니다.`); + } + + // 4. ZTERM - 지급조건 (벤더 제안 조건 우선) + const paymentTermsCode = vendorResponse.vendorPaymentTermsCode || detailData.paymentTermsCode || ''; + if (!paymentTermsCode) { + validationErrors.push(`❌ 지급조건(Payment Terms) - 벤더 견적 또는 RFQ 조건에 지급조건이 설정되지 않았습니다.`); } - const incotermsCode = detailData.incotermsCode || ''; // 빈 값으로 기본값 설정 - // incoterms 테이블에서 description 조회 (INCO2용) + // 5. INCO1 - 인코텀즈 코드 (벤더 제안 조건 우선) + const incotermsCode = vendorResponse.vendorIncotermsCode || detailData.incotermsCode || ''; + if (!incotermsCode) { + validationErrors.push(`❌ 인코텀즈 코드(Incoterms) - 벤더 견적 또는 RFQ 조건에 인코텀즈가 설정되지 않았습니다.`); + } + + // 6. INCO2 - 인코텀즈 상세 설명 (벤더 제안 조건 우선) let incotermsDescription = ''; if (incotermsCode) { - const [incotermsData] = await db - .select({ description: incoterms.description }) - .from(incoterms) - .where(eq(incoterms.code, incotermsCode)) - .limit(1); - - if (incotermsData?.description) { - incotermsDescription = incotermsData.description; + // 우선순위: 1) 벤더 제출 상세 정보, 2) incoterms 테이블 조회, 3) 구매자 제시 상세 정보 + if (vendorResponse.vendorIncotermsDetail) { + incotermsDescription = vendorResponse.vendorIncotermsDetail; } else { - console.warn(`⚠️ 인코텀즈 코드 '${incotermsCode}'에 대한 설명을 찾을 수 없습니다. - detailData.incotermsDetail을 사용합니다.`); - incotermsDescription = detailData.incotermsDetail || ''; + const [incotermsData] = await db + .select({ description: incoterms.description }) + .from(incoterms) + .where(eq(incoterms.code, incotermsCode)) + .limit(1); + + if (incotermsData?.description) { + incotermsDescription = incotermsData.description; + } else if (detailData.incotermsDetail) { + incotermsDescription = detailData.incotermsDetail; + } else { + validationErrors.push(`❌ 인코텀즈 상세 정보(INCO2) - 인코텀즈 코드 '${incotermsCode}'에 대한 설명을 찾을 수 없습니다.`); + } } - } else { - console.warn("⚠️ 인코텀즈 상세 정보(INCO2)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); - incotermsDescription = detailData.incotermsDetail || ''; } - - if (!detailData.taxCode) { - console.warn("⚠️ 세금코드(Tax Code)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); + + // 7. MWSKZ - 세금코드 (벤더 제안 조건 우선) + const taxCode = vendorResponse.vendorTaxCode || detailData.taxCode || ''; + if (!taxCode) { + validationErrors.push(`❌ 세금코드(Tax Code) - 벤더 견적 또는 RFQ 조건에 세금코드가 설정되지 않았습니다.`); } - const taxCode = detailData.taxCode || ''; // 빈 값으로 기본값 설정 + + // PR 아이템 필드를 미리 검증하기 위해 순회 + prItems.forEach((item, index) => { + const itemNum = index + 1; + + if (!item.uom) { + validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 단위(UOM)`); + } + + const quoteItem = quotationItemMap.get(item.id); + if (!quoteItem) { + validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 견적 정보 없음 (PR Item ID: ${item.id})`); + } else { + const unitPrice = Number(quoteItem.unitPrice) || 0; + const totalPrice = Number(quoteItem.totalPrice) || 0; + + if (unitPrice <= 0 || totalPrice <= 0) { + validationErrors.push(`❌ PR 아이템 ${itemNum}번 - 가격 정보 (단가: ${unitPrice}, 총액: ${totalPrice})`); + } + } + }); - if (!detailData.currency && !params.currency) { - console.warn("⚠️ 통화(Currency)가 설정되지 않았습니다. - KRW로 기본 설정합니다."); + // 검증 에러가 있으면 한 번에 throw + if (validationErrors.length > 0) { + const errorMessage = `SAP PO 생성을 위한 필수 필드가 누락되었습니다:\n\n${validationErrors.join('\n')}\n\n위 필드들을 모두 입력해주세요.`; + console.error("❌ 필수 필드 검증 실패:", errorMessage); + throw new Error(errorMessage); } - const currency = detailData.currency || params.currency || 'KRW'; // KRW를 기본값으로 설정 - // ANFNR: rfqsLast.ANFNR 우선, 없으면 rfqCode 사용 (ITB, 일반견적은 ANFNR 없으므로..) - const anfnr = rfqData.ANFNR || rfqData.rfqCode || ''; - if (!anfnr) { - console.warn("⚠️ RFQ 번호(ANFNR 또는 rfqCode)가 없습니다. - 빈 값으로 전송합니다."); - } + // 검증 완료: 이제 안전하게 사용 가능 (타입 단언) + const validAnfnr = anfnr as string; + const validVendorCode = vendorCode as string; + const validPaymentTermsCode = paymentTermsCode as string; + const validIncotermsCode = incotermsCode as string; + const validTaxCode = taxCode as string; // 5. PO 데이터 구성 const poData = { T_Bidding_HEADER: [{ // 필수 필드 - ANFNR: anfnr, - LIFNR: vendorCode, + ANFNR: validAnfnr, + LIFNR: validVendorCode, ZPROC_IND: '9', // 구매 처리 상태: 9 (기존 로그 기준) WAERS: currency, - ZTERM: paymentTermsCode, - INCO1: incotermsCode, + ZTERM: validPaymentTermsCode, + INCO1: validIncotermsCode, INCO2: incotermsDescription.substring(0, 28), // SAP 최대 28자리 제한, incoterms 테이블의 description 사용 - MWSKZ: taxCode, + MWSKZ: validTaxCode, LANDS: vendorCountryCode, // 벤더 국가 코드 사용 ZRCV_DT: getCurrentSAPDate(), - ZATTEN_IND: 'N', // 참석 여부: N (기본값, 실제 데이터 없음) + ZATTEN_IND: 'Y', // 참석 여부: Y (고정값) IHRAN: getCurrentSAPDate(), // Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지) @@ -189,25 +238,18 @@ export async function createPO(params: CreatePOParams) { LSTEL: '', // Loading Point (데이터 없음) }], T_Bidding_ITEM: prItems.map((item, index) => { - if (!item.uom) { - console.warn(`⚠️ PR 아이템 ${index + 1}번의 단위(UOM)가 없습니다. - 빈 값으로 전송합니다.`); - } - - // 견적 아이템에서 실제 가격 정보 가져오기 - const quoteItem = quotationItemMap.get(item.id); - if (!quoteItem) { - console.warn(`⚠️ PR 아이템 ${item.id}에 대한 견적 정보를 찾을 수 없습니다. - 기본값으로 전송합니다.`); - } + // 견적 아이템에서 실제 가격 정보 가져오기 (이미 검증됨) + const quoteItem = quotationItemMap.get(item.id)!; // 검증 통과했으므로 non-null assertion // 가격 계산: SAP은 소수점을 포함한 문자열 형태로 받음 // DB에서 가져온 값을 명시적으로 숫자로 변환 (문자열이나 Decimal 타입일 수 있음) - const unitPrice = Number(quoteItem?.unitPrice) || 0; - const quantity = Number(quoteItem?.quantity || item.quantity) || 0; - const totalPrice = Number(quoteItem?.totalPrice) || (unitPrice * quantity); + const unitPrice = Number(quoteItem.unitPrice); + const quantity = Number(quoteItem.quantity || item.quantity); + const totalPrice = Number(quoteItem.totalPrice) || (unitPrice * quantity); // 납기일 계산 (우선순위: 견적 납기일 > PR 납기일 > 현재일자) let deliveryDate = getCurrentSAPDate(); - if (quoteItem?.vendorDeliveryDate) { + if (quoteItem.vendorDeliveryDate) { deliveryDate = new Date(quoteItem.vendorDeliveryDate).toISOString().split('T')[0].replace(/-/g, ''); } else if (item.deliveryDate) { deliveryDate = new Date(item.deliveryDate).toISOString().split('T')[0].replace(/-/g, ''); @@ -215,12 +257,12 @@ export async function createPO(params: CreatePOParams) { return { // 필수 필드 - ANFNR: anfnr, + ANFNR: validAnfnr, ANFPS: item.prItem || (index + 1).toString().padStart(5, '0'), // PR Item Number 사용 - LIFNR: vendorCode, + LIFNR: validVendorCode, NETPR: unitPrice.toFixed(2), // 단가 (소수점 2자리) PEINH: '1', // 가격 단위: 1 (표준값, 1단위당 가격) - BPRME: item.uom || '', + BPRME: item.uom!, // 검증 통과했으므로 non-null assertion NETWR: totalPrice.toFixed(2), // 순액 (세금 제외) BRTWR: totalPrice.toFixed(2), // 총액: SAP이 taxCode(MWSKZ)로 세금 계산하도록 순액과 동일하게 전송 LFDAT: deliveryDate, @@ -229,31 +271,28 @@ export async function createPO(params: CreatePOParams) { ZCON_NO_PO: item.prNo || '', // PR Consolidation Number EBELP: '', // Series PO Item Seq (시리즈 PO가 아니면 빈 값) }; - }), - T_PR_RETURN: prItems.map((item, index) => ({ - // 필수 필드 - ANFNR: anfnr, - ANFPS: item.prItem || (index + 1).toString().padStart(5, '0'), - EBELN: item.prNo || rfqData.prNumber || '', - EBELP: item.prItem || (index + 1).toString().padStart(5, '0'), - MSGTY: 'S', // Message Type: S (Standard/Success) - - // Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지) - MSGTXT: 'PO Creation from RFQ', // Message Text - })) + }) + // T_PR_RETURN은 응답용 필드이므로 요청에 포함하지 않음 }; console.log('📤 SAP으로 PO 전송 시작:', { - ANFNR: anfnr, - LIFNR: vendorCode, + ANFNR: validAnfnr, + LIFNR: validVendorCode, vendorName: vendorData.vendorName, vendorCountry: vendorCountryCode, itemCount: prItems.length, quotationItemCount: quotationItems.length, totalAmount: params.totalAmount, currency: currency, - taxCode: taxCode, - incoterms: `${incotermsCode} - ${incotermsDescription}`, + paymentTerms: validPaymentTermsCode, + incoterms: `${validIncotermsCode} - ${incotermsDescription}`, + taxCode: validTaxCode, + dataSource: { + currency: vendorResponse.vendorCurrency ? '벤더 견적' : (detailData.currency ? '구매자 조건' : 'params'), + paymentTerms: vendorResponse.vendorPaymentTermsCode ? '벤더 견적' : '구매자 조건', + incoterms: vendorResponse.vendorIncotermsCode ? '벤더 견적' : '구매자 조건', + taxCode: vendorResponse.vendorTaxCode ? '벤더 견적' : '구매자 조건', + } }); // 디버깅: 전송 데이터 전체 로그 (서버 측 로그이므로 모든 정보 포함) @@ -269,7 +308,7 @@ export async function createPO(params: CreatePOParams) { console.log('✅ SAP PO 전송 성공:', sapResult); // 7. 실제 PO 번호 추출 (SOAP 응답에서 추출하거나 ANFNR 사용) - const actualPoNumber = sapResult.bidding_number || anfnr; + const actualPoNumber = sapResult.bidding_number || validAnfnr; // 8. DB에 실제 PO 번호 저장 및 RFQ 상태 업데이트 await db.transaction(async (tx) => { @@ -312,10 +351,30 @@ export async function createPO(params: CreatePOParams) { }; } catch (error) { console.error("❌ PO 생성 오류:", error); - return { + + // 에러 객체에서 추가 정보 추출 (SOAP 응답 포함) + const errorResponse: { + success: false; + error: string; + responseData?: string; + statusCode?: number; + } = { success: false, error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다." }; + + // SOAP 응답 에러인 경우 상세 정보 추가 + if (error instanceof SoapResponseError) { + if (error.responseText) { + errorResponse.responseData = error.responseText; + console.error("📄 SAP 응답 내용:", error.responseText); + } + if (error.statusCode) { + errorResponse.statusCode = error.statusCode; + } + } + + return errorResponse; } } diff --git a/lib/soap/ecc/mapper/common-mapper-utils.ts b/lib/soap/ecc/mapper/common-mapper-utils.ts index 2199490a..ed655e0e 100644 --- a/lib/soap/ecc/mapper/common-mapper-utils.ts +++ b/lib/soap/ecc/mapper/common-mapper-utils.ts @@ -307,6 +307,57 @@ export interface ProjectInfo { } /** + * 사번(PERNR)으로 사용자 정보 조회 함수 + * PERNR(사번)으로 users 테이블의 employeeNumber와 매칭하여 사용자 정보 조회 + */ +export async function findUserInfoByPERNR(PERNR: string | null): Promise<{ + userId: number; + userName: string; + userEmail: string | null; + userPhone: string | null; + employeeNumber: string | null; +} | null> { + try { + debugLog('사번으로 사용자 조회 시작', { PERNR }); + + if (!PERNR) { + debugError('PERNR이 null 또는 undefined', { PERNR }); + return null; + } + + const userResult = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + phone: users.phone, + employeeNumber: users.employeeNumber + }) + .from(users) + .where(eq(users.employeeNumber, PERNR)) + .limit(1); + + if (userResult.length === 0) { + debugError('PERNR에 해당하는 사용자를 찾을 수 없음', { PERNR }); + return null; + } + + const userInfo = { + userId: userResult[0].id, + userName: userResult[0].name, + userEmail: userResult[0].email, + userPhone: userResult[0].phone, + employeeNumber: userResult[0].employeeNumber + }; + debugSuccess('사번으로 사용자 정보 찾음', { PERNR, userInfo }); + return userInfo; + } catch (error) { + debugError('사번으로 사용자 조회 중 오류 발생', { PERNR, error }); + return null; + } +} + +/** * 협력업체 코드(LIFNR)로 vendorId 찾기 * LIFNR = 벤더코드 (ex. A0001234) * vendors 테이블의 vendorCode 필드와 비교하여 vendorId를 찾음 diff --git a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts index 85fbb918..d08bc5fb 100644 --- a/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/rfq-and-pr-mapper.ts @@ -24,6 +24,7 @@ import { findUserInfoByEKGRP, findProjectInfoByPSPID, parseSAPDateTime, + findUserInfoByPERNR, } from './common-mapper-utils'; // ECC 데이터 타입 정의 @@ -154,11 +155,13 @@ export async function mapECCRfqHeaderToRfqLast( const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null); const inChargeUserId = inChargeUserInfo?.userId || null; - // 대표 PR Item 기반으로 projectId, itemCode, itemName 설정 (없으면 첫번째 PR Item 사용) + // 대표 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 engPicName: string | null = null; + let engPicEmployeeNumber: string | null = null; let representativeItem: ECCBidItem | undefined; if (firstItem) { @@ -185,6 +188,15 @@ export async function mapECCRfqHeaderToRfqLast( // prNumber: 대표 PR의 BANFN 또는 첫번째 PR의 ZREQ_FN 값 prNumber = representativeItem?.BANFN || firstItem.ZREQ_FN || null; + + // 설계담당자 정보: 대표 PR Item의 PERNR로 조회 + if (targetItem.PERNR) { + const engPicInfo = await findUserInfoByPERNR(targetItem.PERNR); + if (engPicInfo) { + engPicName = engPicInfo.userName; + engPicEmployeeNumber = engPicInfo.employeeNumber; + } + } } // 매핑 @@ -199,7 +211,8 @@ export async function mapECCRfqHeaderToRfqLast( rfqSendDate: null, // ECC에서 제공되지 않음 status: 'RFQ 생성', // 한글 상태로 변경 rfqSealedYn: false, - EngPicName: null, // ECC에서 제공되지 않음 + EngPicName: engPicName, // 대표 PR Item의 PERNR로 조회한 설계담당자명 + EngPicEmployeeNumber: engPicEmployeeNumber, // 대표 PR Item의 PERNR(사번) packageNo: null, // ECC에서 제공되지 않음 packageName: null, // ECC에서 제공되지 않음 remark: null, diff --git a/lib/soap/ecc/send/create-po.ts b/lib/soap/ecc/send/create-po.ts index 0984a208..1e21d39c 100644 --- a/lib/soap/ecc/send/create-po.ts +++ b/lib/soap/ecc/send/create-po.ts @@ -44,23 +44,11 @@ export interface POItemData { EBELP?: string; // Series PO Item Seq } -// PR 반환 데이터 타입 -export interface PRReturnData { - ANFNR: string; // PR Request Number (M) - ANFPS: string; // Item Number of PR Request (M) - EBELN: string; // Purchase Requisition Number (M) - EBELP: string; // Item Number of Purchase Requisition (M) - MSGTY: string; // Message Type (M) - MSGTXT?: string; // Message Text -} - // PO 생성 요청 데이터 타입 +// 참고: T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음 export interface POCreateRequest { T_Bidding_HEADER: POHeaderData[]; T_Bidding_ITEM: POItemData[]; - T_PR_RETURN: PRReturnData[]; - EV_ERDAT?: string; // Extract Date - EV_ERZET?: string; // Extract Time } @@ -72,10 +60,8 @@ function createPOSoapBodyContent(poData: POCreateRequest): Record<string, unknow return { 'p1:MT_P2MM3015_S': { // WSDL에서 사용하는 p1 접두사 적용 'T_Bidding_HEADER': poData.T_Bidding_HEADER, - 'T_Bidding_ITEM': poData.T_Bidding_ITEM, - 'T_PR_RETURN': poData.T_PR_RETURN, - ...(poData.EV_ERDAT && { 'EV_ERDAT': poData.EV_ERDAT }), - ...(poData.EV_ERZET && { 'EV_ERZET': poData.EV_ERZET }) + 'T_Bidding_ITEM': poData.T_Bidding_ITEM + // T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음 } }; } @@ -112,19 +98,7 @@ function validatePOData(poData: POCreateRequest): { isValid: boolean; errors: st }); } - // PR 반환 데이터 검증 - if (!poData.T_PR_RETURN || poData.T_PR_RETURN.length === 0) { - errors.push('T_PR_RETURN은 필수입니다.'); - } else { - poData.T_PR_RETURN.forEach((prReturn, index) => { - const requiredFields = ['ANFNR', 'ANFPS', 'EBELN', 'EBELP', 'MSGTY']; - requiredFields.forEach(field => { - if (!prReturn[field as keyof PRReturnData]) { - errors.push(`T_PR_RETURN[${index}].${field}는 필수입니다.`); - } - }); - }); - } + // T_PR_RETURN은 응답용 필드이므로 검증하지 않음 return { isValid: errors.length === 0, @@ -165,7 +139,7 @@ async function sendPOToECC(poData: POCreateRequest): Promise<SoapSendResult> { }; console.log(`📤 PO 생성 요청 전송 시작 - ANFNR: ${poData.T_Bidding_HEADER[0]?.ANFNR}`); - console.log(`🔍 헤더 ${poData.T_Bidding_HEADER.length}개, 아이템 ${poData.T_Bidding_ITEM.length}개, PR 반환 ${poData.T_PR_RETURN.length}개`); + console.log(`🔍 헤더 ${poData.T_Bidding_HEADER.length}개, 아이템 ${poData.T_Bidding_ITEM.length}개`); // SOAP XML 전송 const result = await sendSoapXml(config, logInfo); @@ -341,17 +315,8 @@ export async function createTestPurchaseOrder(): Promise<{ LFDAT: getCurrentSAPDate(), ZCON_NO_PO: 'CON001', EBELP: '00001' - }], - T_PR_RETURN: [{ - ANFNR: 'TEST001', - ANFPS: '00001', - EBELN: 'PR001', - EBELP: '00001', - MSGTY: 'S', - MSGTXT: 'Test message' - }], - EV_ERDAT: getCurrentSAPDate(), - EV_ERZET: getCurrentSAPTime() + }] + // T_PR_RETURN, EV_ERDAT, EV_ERZET는 응답용 필드이므로 요청에 포함하지 않음 }; const result = await sendPOToECC(testPOData); diff --git a/lib/soap/sender.ts b/lib/soap/sender.ts index d12665cb..c0be780d 100644 --- a/lib/soap/sender.ts +++ b/lib/soap/sender.ts @@ -3,43 +3,13 @@ import { withSoapLogging } from "@/lib/soap/utils"; import { XMLBuilder } from 'fast-xml-parser'; import { debugLog, debugError, debugWarn, debugSuccess } from '@/lib/debug-utils'; - -// 기본 인증 정보 타입 -export interface SoapAuthConfig { - username?: string; - password?: string; -} - -// SOAP 전송 설정 타입 -export interface SoapSendConfig { - endpoint: string; - envelope: Record<string, unknown>; - soapAction?: string; - timeout?: number; - retryCount?: number; - retryDelay?: number; - namespace?: string; // 네임스페이스를 동적으로 설정할 수 있도록 추가 - prefix: string; // 네임스페이스 접두사 (필수) -} - -// 로깅 정보 타입 -export interface SoapLogInfo { - direction: 'INBOUND' | 'OUTBOUND'; - system: string; - interface: string; -} - -// 전송 결과 타입 -export interface SoapSendResult { - success: boolean; - message: string; - responseText?: string; - statusCode?: number; - headers?: Record<string, string>; - endpoint?: string; - requestXml?: string; - requestHeaders?: Record<string, string>; -} +import type { + SoapAuthConfig, + SoapSendConfig, + SoapLogInfo, + SoapSendResult +} from './types'; +import { SoapResponseError } from './types'; // 기본 환경변수에서 인증 정보 가져오기 function getDefaultAuth(): SoapAuthConfig { @@ -154,6 +124,7 @@ export async function sendSoapXml( } } + let responseText = ''; const result = await withSoapLogging( logInfo.direction, logInfo.system, @@ -161,17 +132,17 @@ export async function sendSoapXml( xmlData, async () => { // 타임아웃 설정 + let response: Response; if (config.timeout) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { - const response = await fetch(config.endpoint, { + response = await fetch(config.endpoint, { ...fetchOptions, signal: controller.signal }); clearTimeout(timeoutId); - return response; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { @@ -180,14 +151,20 @@ export async function sendSoapXml( throw error; } } else { - return await fetch(config.endpoint, fetchOptions); + response = await fetch(config.endpoint, fetchOptions); } - } + + // 응답 텍스트 읽기 (로깅을 위해 여기서 읽음) + responseText = await response.text(); + + return response; + }, + // 응답 데이터 추출 함수: responseText를 반환 + () => responseText ); // 응답 처리 const response = result as Response; - const responseText = await response.text(); // 응답 헤더 수집 (디버깅용) const responseHeadersDebug: Record<string, string> = {}; @@ -203,22 +180,52 @@ export async function sendSoapXml( }); debugLog('🔍 응답 바디 (전체):', responseText); - // HTTP 상태 코드가 비정상이거나 SOAP Fault 포함 시 실패로 처리하되 본문을 그대로 반환 - if (!response.ok || responseText.includes('soap:Fault') || responseText.includes('SOAP:Fault')) { + // SAP 응답 에러 체크: <MSGTY>E</MSGTY> 또는 <EV_TYPE>E</EV_TYPE> 패턴 감지 + const hasSapError = responseText.includes('<MSGTY>E</MSGTY>') || + responseText.includes('<EV_TYPE>E</EV_TYPE>'); + + // HTTP 상태 코드가 비정상이거나 SOAP Fault 또는 SAP 에러 포함 시 실패로 처리 + if (!response.ok || + responseText.includes('soap:Fault') || + responseText.includes('SOAP:Fault') || + hasSapError) { const responseHeaders: Record<string, string> = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); - return { - success: false, - message: !response.ok ? `HTTP ${response.status}: ${response.statusText}` : 'SOAP Fault', + + // 에러 메시지 결정 및 상세 메시지 추출 + let errorMessage = ''; + if (!response.ok) { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } else if (hasSapError) { + // SAP 응답에서 에러 메시지 추출 시도 + const msgtxtMatch = responseText.match(/<MSGTXT>(.*?)<\/MSGTXT>/); + const evMessageMatch = responseText.match(/<EV_MESSAGE>(.*?)<\/EV_MESSAGE>/); + const detailMessage = msgtxtMatch?.[1] || evMessageMatch?.[1] || ''; + + errorMessage = 'SAP 응답 에러: MSGTY=E 또는 EV_TYPE=E 감지'; + if (detailMessage) { + errorMessage += ` - ${detailMessage}`; + } + } else { + // SOAP Fault에서 메시지 추출 시도 + const faultStringMatch = responseText.match(/<faultstring>(.*?)<\/faultstring>/); + const faultMessage = faultStringMatch?.[1] || 'SOAP Fault'; + errorMessage = faultMessage; + } + + debugError('❌ SOAP 응답 에러 감지:', errorMessage); + + // 커스텀 에러 객체 생성 (responseText 포함) + throw new SoapResponseError(errorMessage, { responseText, statusCode: response.status, headers: responseHeaders, endpoint: config.endpoint, requestXml: xmlData, requestHeaders - }; + }); } // 응답 헤더 수집 diff --git a/lib/soap/types.ts b/lib/soap/types.ts new file mode 100644 index 00000000..dac8f83b --- /dev/null +++ b/lib/soap/types.ts @@ -0,0 +1,64 @@ +// SOAP 관련 타입 정의 + +// 기본 인증 정보 타입 +export interface SoapAuthConfig { + username?: string; + password?: string; +} + +// SOAP 전송 설정 타입 +export interface SoapSendConfig { + endpoint: string; + envelope: Record<string, unknown>; + soapAction?: string; + timeout?: number; + retryCount?: number; + retryDelay?: number; + namespace?: string; // 네임스페이스를 동적으로 설정할 수 있도록 추가 + prefix: string; // 네임스페이스 접두사 (필수) +} + +// 로깅 정보 타입 +export interface SoapLogInfo { + direction: 'INBOUND' | 'OUTBOUND'; + system: string; + interface: string; +} + +// 전송 결과 타입 +export interface SoapSendResult { + success: boolean; + message: string; + responseText?: string; + statusCode?: number; + headers?: Record<string, string>; + endpoint?: string; + requestXml?: string; + requestHeaders?: Record<string, string>; +} + +// SOAP 에러 타입 (응답 정보 포함) +export class SoapResponseError extends Error { + responseText?: string; + statusCode?: number; + headers?: Record<string, string>; + endpoint?: string; + requestXml?: string; + requestHeaders?: Record<string, string>; + + constructor(message: string, details?: { + responseText?: string; + statusCode?: number; + headers?: Record<string, string>; + endpoint?: string; + requestXml?: string; + requestHeaders?: Record<string, string>; + }) { + super(message); + this.name = 'SoapResponseError'; + if (details) { + Object.assign(this, details); + } + } +} + diff --git a/lib/soap/utils.ts b/lib/soap/utils.ts index 57e3b280..809dd46d 100644 --- a/lib/soap/utils.ts +++ b/lib/soap/utils.ts @@ -466,6 +466,7 @@ export async function cleanupOldSoapLogs(): Promise<void> { * @param interfaceName 인터페이스명 * @param requestData 요청 데이터 * @param processor 실제 비즈니스 로직 함수 + * @param extractResponse 응답 데이터 추출 함수 (선택사항) * @returns 처리 결과 */ export async function withSoapLogging<T>( @@ -473,7 +474,8 @@ export async function withSoapLogging<T>( system: string, interfaceName: string, requestData: string, - processor: () => Promise<T> + processor: () => Promise<T>, + extractResponse?: (result: T) => string | undefined ): Promise<T> { let logId: number | null = null; @@ -484,10 +486,16 @@ export async function withSoapLogging<T>( // 2. 실제 처리 실행 const result = await processor(); - // 3. 성공 로그 완료 - await completeSoapLog(logId, true); + // 3. 응답 데이터 추출 (제공된 경우) + let responseData: string | undefined; + if (extractResponse) { + responseData = extractResponse(result); + } + + // 4. 성공 로그 완료 (응답 데이터 포함) + await completeSoapLog(logId, true, responseData); - // 4. 로그 정리 (백그라운드) + // 5. 로그 정리 (백그라운드) cleanupOldSoapLogs().catch(error => console.error('백그라운드 로그 정리 실패:', error) ); @@ -495,12 +503,18 @@ export async function withSoapLogging<T>( return result; } catch (error) { - // 5. 실패 로그 완료 + // 6. 실패 로그 완료 if (logId !== null) { + // 에러 객체에 응답 데이터가 포함되어 있는지 확인 + let errorResponseData: string | undefined; + if (error && typeof error === 'object' && 'responseText' in error) { + errorResponseData = (error as { responseText?: string }).responseText; + } + await completeSoapLog( logId, false, - undefined, + errorResponseData, error instanceof Error ? error.message : 'Unknown error' ); } diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx index a87cd0e2..77ef77f7 100644 --- a/lib/swp/table/swp-document-detail-dialog.tsx +++ b/lib/swp/table/swp-document-detail-dialog.tsx @@ -192,85 +192,97 @@ export function SwpDocumentDetailDialog({ } }; - // Revision별로 Activity 그룹핑 (rowspan용) + // Revision별로 Activity 그룹핑 및 정렬 (rowspan용) const groupedActivities = useMemo(() => { + // 1. REV 내림차순, createDate 내림차순으로 정렬 + const sortedActivities = [...activities].sort((a, b) => { + // REV 비교 (내림차순) + const revCompare = b.revNo.localeCompare(a.revNo); + if (revCompare !== 0) return revCompare; + + // 같은 REV 내에서는 createDate 내림차순 + return b.createDate.localeCompare(a.createDate); + }); + + // 2. 그룹핑 const groups: Map<string, ActivityRow[]> = new Map(); - activities.forEach((activity) => { + sortedActivities.forEach((activity) => { const key = `${activity.revNo}|${activity.stage}`; if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(activity); }); + return groups; }, [activities]); return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[95vw] max-h-[80vh] overflow-hidden flex flex-col"> + <DialogContent className="max-w-[95vw] h-[90vh] overflow-hidden flex flex-col"> <DialogHeader> - <DialogTitle>문서 리비전 히스토리</DialogTitle> + <DialogTitle className="text-base">문서 리비전 히스토리</DialogTitle> {document && ( - <DialogDescription> + <DialogDescription className="text-xs"> {document.DOC_NO} - {document.DOC_TITLE} </DialogDescription> )} </DialogHeader> {document && ( - <div className="flex-1 flex flex-col space-y-4 overflow-hidden min-h-0"> + <div className="flex-1 flex flex-col space-y-2 overflow-hidden"> {/* 문서 정보 */} - <div className="grid grid-cols-1 md:grid-cols-5 gap-4 p-4 bg-muted/30 rounded-lg"> - <div> - <span className="text-sm font-semibold">프로젝트:</span> - <div className="text-sm">{document.PROJ_NO}</div> + <div className="flex items-center gap-4 px-3 py-2 bg-muted/30 rounded text-xs"> + <div className="flex items-center gap-1"> + <span className="font-semibold">프로젝트:</span> + <span>{document.PROJ_NO}</span> {document.PROJ_NM && ( - <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div> + <span className="text-muted-foreground">({document.PROJ_NM})</span> )} </div> - <div> - <span className="text-sm font-semibold">패키지:</span> - <div className="text-sm">{document.PKG_NO || "-"}</div> + <div className="flex items-center gap-1"> + <span className="font-semibold">패키지:</span> + <span>{document.PKG_NO || "-"}</span> </div> - <div> - <span className="text-sm font-semibold">업체:</span> - <div className="text-sm">{document.CPY_NM || "-"}</div> + <div className="flex items-center gap-1"> + <span className="font-semibold">업체:</span> + <span>{document.CPY_NM || "-"}</span> {document.VNDR_CD && ( - <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div> + <span className="text-muted-foreground">({document.VNDR_CD})</span> )} </div> - <div> - <span className="text-sm font-semibold">최신 리비전:</span> - <div className="text-sm">{document.LTST_REV_NO || "-"}</div> + <div className="flex items-center gap-1"> + <span className="font-semibold">최신 리비전:</span> + <span>{document.LTST_REV_NO || "-"}</span> </div> - <div> - <span className="text-sm font-semibold">총 Activity:</span> - <div className="text-sm">{activities.length}개</div> + <div className="flex items-center gap-1"> + <span className="font-semibold">총 Activity:</span> + <span>{activities.length}개</span> </div> </div> {/* Activity 테이블 */} {isLoading ? ( <div className="flex items-center justify-center p-8"> - <Loader2 className="h-6 w-6 animate-spin" /> - <span className="ml-2">리비전 트리 로딩 중...</span> + <Loader2 className="h-5 w-5 animate-spin" /> + <span className="ml-2 text-sm">리비전 트리 로딩 중...</span> </div> ) : activities.length > 0 ? ( <> {/* Activity 테이블 (위) */} - <div className="flex-1 overflow-auto border rounded-lg min-h-0"> + <div className="h-[40vh] overflow-auto border rounded-lg"> <Table> <TableHeader className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead className="w-[80px]">Rev</TableHead> - <TableHead className="w-[80px]">Stage</TableHead> - <TableHead className="w-[80px]">IN/OUT</TableHead> - <TableHead className="w-[100px]">Status</TableHead> - <TableHead className="min-w-[150px]">Transmittal No</TableHead> - <TableHead className="min-w-[150px]">Activity No</TableHead> - <TableHead className="min-w-[100px]">Ref Activity</TableHead> - <TableHead className="w-[120px]">Modified</TableHead> - <TableHead className="w-[80px]">By</TableHead> + <TableRow className="text-xs"> + <TableHead className="w-[70px] text-xs h-8">Rev</TableHead> + <TableHead className="w-[70px] text-xs h-8">Stage</TableHead> + <TableHead className="w-[70px] text-xs h-8">IN/OUT</TableHead> + <TableHead className="w-[90px] text-xs h-8">Status</TableHead> + <TableHead className="min-w-[130px] text-xs h-8">Transmittal No</TableHead> + <TableHead className="min-w-[130px] text-xs h-8">Activity No</TableHead> + <TableHead className="min-w-[90px] text-xs h-8">Ref Activity</TableHead> + <TableHead className="w-[100px] text-xs h-8">Modified</TableHead> + <TableHead className="w-[70px] text-xs h-8">By</TableHead> </TableRow> </TableHeader> <TableBody> @@ -280,7 +292,7 @@ export function SwpDocumentDetailDialog({ <TableRow key={activity.actvNo} className={cn( - "cursor-pointer hover:bg-muted/50", + "cursor-pointer hover:bg-muted/50 h-8", selectedActivity?.actvNo === activity.actvNo && "bg-blue-50 hover:bg-blue-100" )} @@ -289,7 +301,7 @@ export function SwpDocumentDetailDialog({ {/* Rev 컬럼 (첫 행만 표시, rowspan) */} {idx === 0 && ( <TableCell - className="font-mono text-sm font-semibold align-top border-r" + className="font-mono text-xs font-semibold align-top border-r py-1" rowSpan={groupActivities.length} > {revNo} @@ -298,45 +310,41 @@ export function SwpDocumentDetailDialog({ {/* Stage 컬럼 (첫 행만 표시, rowspan) */} {idx === 0 && ( <TableCell - className="align-top border-r text-sm" + className="align-top border-r text-xs py-1" rowSpan={groupActivities.length} > {stage} </TableCell> )} - <TableCell> + <TableCell className="py-1"> <Badge variant="outline" - className={ + className={cn( + "text-[10px] h-4 px-1", activity.inOut === "IN" ? "bg-blue-100 text-blue-800" : "bg-green-100 text-green-800" - } + )} > {activity.inOut} </Badge> </TableCell> - <TableCell> - <div className="text-sm"> - <div className="font-medium">{activity.statusName}</div> - <div className="text-xs text-muted-foreground"> - {activity.statusCode} - </div> - </div> + <TableCell className="text-xs py-1"> + <div className="font-medium">{activity.statusName}</div> </TableCell> - <TableCell className="text-sm"> + <TableCell className="text-xs py-1"> {activity.transmittalNo || "-"} </TableCell> - <TableCell className="font-mono text-xs"> + <TableCell className="font-mono text-xs py-1"> {activity.actvNo} </TableCell> - <TableCell className="font-mono text-xs"> + <TableCell className="font-mono text-xs py-1"> {activity.refActivityNo || "-"} </TableCell> - <TableCell className="text-xs"> + <TableCell className="text-xs py-1"> {formatSwpDateShort(activity.createDate)} </TableCell> - <TableCell className="text-xs"> + <TableCell className="text-xs py-1"> {activity.createEmpNo} </TableCell> </TableRow> @@ -347,57 +355,53 @@ export function SwpDocumentDetailDialog({ </div> {/* 파일 목록 (아래) */} - <div className="border rounded-lg overflow-hidden" style={{ height: "250px" }}> - <div className="p-3 bg-muted/50 border-b"> - <h3 className="font-semibold text-sm">파일 목록</h3> + <div className="border rounded-lg overflow-hidden h-[30vh] flex flex-col"> + <div className="px-3 py-1.5 bg-muted/50 border-b flex-shrink-0"> + <h3 className="font-semibold text-xs">파일 목록</h3> {selectedActivity ? ( - <> - <p className="text-xs text-muted-foreground mt-1"> - Activity: {selectedActivity.actvNo} - </p> - <p className="text-xs text-muted-foreground"> - Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut} - </p> - </> + <p className="text-[10px] text-muted-foreground mt-0.5"> + Activity: {selectedActivity.actvNo} / Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut} + </p> ) : ( - <p className="text-xs text-muted-foreground mt-1"> + <p className="text-[10px] text-muted-foreground mt-0.5"> Activity를 선택하면 파일 목록이 표시됩니다 </p> )} </div> - <div className="overflow-auto p-3" style={{ height: "calc(250px - 80px)" }}> + <div className="overflow-auto flex-1"> {selectedActivity ? ( isLoadingFiles ? ( <div className="flex items-center justify-center h-full"> - <Loader2 className="h-5 w-5 animate-spin" /> - <span className="ml-2 text-sm">파일 로딩 중...</span> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-xs">파일 로딩 중...</span> </div> ) : activityFiles.length > 0 ? ( <Table> - <TableHeader> + <TableHeader className="sticky top-0 bg-background"> <TableRow> - <TableHead className="min-w-[200px]">파일명</TableHead> - <TableHead className="w-[100px]">크기</TableHead> - <TableHead className="w-[120px]">날짜</TableHead> - <TableHead className="w-[100px]">다운로드</TableHead> + <TableHead className="min-w-[200px] text-xs h-8">파일명</TableHead> + <TableHead className="w-[90px] text-xs h-8">크기</TableHead> + <TableHead className="w-[100px] text-xs h-8">날짜</TableHead> + <TableHead className="w-[90px] text-xs h-8">다운로드</TableHead> </TableRow> </TableHeader> <TableBody> {activityFiles.map((file) => ( - <TableRow key={file.FILE_SEQ}> - <TableCell className="font-medium"> + <TableRow key={file.FILE_SEQ} className="h-8"> + <TableCell className="font-medium text-xs py-1"> {file.FILE_NM} </TableCell> - <TableCell className="text-sm text-muted-foreground"> + <TableCell className="text-xs text-muted-foreground py-1"> {file.FILE_SZ ? formatFileSize(file.FILE_SZ) : "-"} </TableCell> - <TableCell className="text-sm text-muted-foreground"> + <TableCell className="text-xs text-muted-foreground py-1"> {file.CRTE_DTM ? formatSwpDateShort(file.CRTE_DTM) : "-"} </TableCell> - <TableCell> + <TableCell className="py-1"> <Button variant="outline" size="sm" + className="h-6 px-2 text-[10px]" onClick={() => handleDownloadFile(file.FILE_NM, document.OWN_DOC_NO || document.DOC_NO)} > <Download className="h-3 w-3 mr-1" /> @@ -409,17 +413,17 @@ export function SwpDocumentDetailDialog({ </TableBody> </Table> ) : ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + <div className="flex items-center justify-center h-full text-xs text-muted-foreground"> <div className="text-center"> - <AlertCircle className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <AlertCircle className="h-6 w-6 mx-auto mb-1 opacity-50" /> <p>파일이 없습니다</p> </div> </div> ) ) : ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground"> + <div className="flex items-center justify-center h-full text-xs text-muted-foreground"> <div className="text-center"> - <FileIcon className="h-12 w-12 mx-auto mb-2 opacity-30" /> + <FileIcon className="h-8 w-8 mx-auto mb-1 opacity-30" /> <p>Activity를 선택해주세요</p> </div> </div> @@ -429,8 +433,8 @@ export function SwpDocumentDetailDialog({ </> ) : ( <div className="p-8 text-center text-muted-foreground"> - <AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" /> - <p>Activity 정보가 없습니다</p> + <AlertCircle className="h-10 w-10 mx-auto mb-2 opacity-50" /> + <p className="text-sm">Activity 정보가 없습니다</p> </div> )} </div> diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx index 633191a1..430447f4 100644 --- a/lib/swp/table/swp-inbox-table.tsx +++ b/lib/swp/table/swp-inbox-table.tsx @@ -181,6 +181,10 @@ export function SwpInboxTable({ docGroups.forEach((docFiles, docNo) => { const totalDocFileCount = docFiles.length; let isFirstInDoc = true; + + // Document의 첫 번째 파일에서 PKG_NO 가져오기 + const firstDocFile = docFiles[0]; + const docPkgNo = firstDocFile?.PKG_NO || null; // 3단계: ACTV_SEQ 기준으로 그룹화 (최신 Rev 필터링 제거) const activityGroups = new Map<string, SwpFileApiResponse[]>(); @@ -218,7 +222,7 @@ export function SwpInboxTable({ actvNo: file.ACTV_NO || null, crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER note1: firstActivityFile.NOTE1 || null, // Activity 첫 파일의 DC Note - pkgNo: file.PKG_NO || null, + pkgNo: docPkgNo, // Document 레벨의 PKG_NO file, uploadDate: file.CRTE_DTM, isFirstInUpload, @@ -472,13 +476,13 @@ export function SwpInboxTable({ </TableHead> <TableHead className="w-[100px]">Upload ID</TableHead> <TableHead className="w-[200px]">Document No</TableHead> + <TableHead className="w-[120px]">PKG NO</TableHead> <TableHead className="w-[80px]">Rev No</TableHead> <TableHead className="w-[80px]">Stage</TableHead> <TableHead className="w-[120px]">Status</TableHead> <TableHead className="w-[100px]">Activity</TableHead> <TableHead className="w-[120px]">Upload ID (User)</TableHead> <TableHead className="w-[150px]">DC Note</TableHead> - <TableHead className="w-[120px]">PKG NO</TableHead> <TableHead className="w-[400px]">Attachment File</TableHead> <TableHead className="w-[180px]">Upload Date</TableHead> </TableRow> @@ -519,6 +523,13 @@ export function SwpInboxTable({ </TableCell> ) : null} + {/* PKG NO - Document의 첫 파일에만 표시 */} + {row.isFirstInDoc ? ( + <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}> + {row.pkgNo || <span className="text-muted-foreground">-</span>} + </TableCell> + ) : null} + {/* Rev No - Rev의 첫 파일에만 표시 */} {row.isFirstInRev ? ( <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}> @@ -578,11 +589,6 @@ export function SwpInboxTable({ </TableCell> ) : null} - {/* PKG NO - 각 파일마다 표시 */} - <TableCell className="font-mono text-sm"> - {row.pkgNo || <span className="text-muted-foreground">-</span>} - </TableCell> - {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */} <TableCell className="max-w-[400px]"> {row.file ? ( diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts index 35058a65..8c66f234 100644 --- a/lib/vendor-document-list/dolce-upload-service.ts +++ b/lib/vendor-document-list/dolce-upload-service.ts @@ -1,9 +1,8 @@ // lib/vendor-document-list/dolce-upload-service.ts import db from "@/db/db" import { documents, revisions, documentAttachments, contracts, projects, vendors, issueStages } from "@/db/schema" -import { eq, and, desc, sql, inArray, min } from "drizzle-orm" +import { eq, and, inArray, min } from "drizzle-orm" import { v4 as uuidv4 } from "uuid" -import path from "path" import * as crypto from "crypto" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" @@ -59,13 +58,65 @@ interface DOLCEDocument { RegCompanyCode: string } +interface RevisionAttachment { + id: number + uploadId: string | null + fileId: string | null + fileName: string + filePath: string + fileType: string | null + fileSize: number | null + createdAt: Date +} + +interface RevisionWithAttachments { + id: number + registerId: string | number | null + revision: string + revisionStatus: string | null + uploaderId: string | number | null + uploaderName: string | null + submittedDate: string | null + comment: string | null + usage: string | null + usageType: string | null + externalUploadId: string | null + externalRegisterId: number + externalSentAt: string | null + serialNo: number | null + issueStageId: number + stageName: string | null + documentId: number + documentNo: string + documentName: string + drawingKind: string | null + drawingMoveGbn: string | null + discipline: string | null + registerGroupId: number | null + category?: string | null + cGbn: string | null + dGbn: string | null + degreeGbn: string | null + deptGbn: string | null + jGbn: string | null + sGbn: string | null + manager: string | null + managerENM: string | null + managerNo: string | null + shiDrawingNo: string | null + externalDocumentId: string | null + externalSystemType: string | null + externalSyncedAt: Date | null + attachments: RevisionAttachment[] +} + interface DOLCEFileMapping { - CGbn?: string - Category?: string + CGbn?: string | null + Category?: string | null CheckBox: string - DGbn?: string - DegreeGbn?: string - DeptGbn?: string + DGbn?: string | null + DegreeGbn?: string | null + DeptGbn?: string | null Discipline: string DrawingKind: string DrawingMoveGbn: string @@ -73,7 +124,7 @@ interface DOLCEFileMapping { DrawingNo: string DrawingUsage: string FileNm: string - JGbn?: string + JGbn?: string | null Manager: string MappingYN: string NewOrNot: string @@ -82,8 +133,8 @@ interface DOLCEFileMapping { RegisterGroupId: number RegisterKindCode: string RegisterSerialNo: number - RevNo?: string - SGbn?: string + RevNo?: string | null + SGbn?: string | null UploadId: string } @@ -115,19 +166,24 @@ class DOLCEUploadService { async uploadToDoLCE( projectId: number, revisionIds: number[], - userId: string, - userName?: string + userId: string ): Promise<DOLCEUploadResult> { try { console.log(`Starting DOLCE upload for contract ${projectId}, revisions: ${revisionIds.join(', ')}`) - // 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등) + // 1. 사용자 정보 조회 (DOLCE API에 필요한 정보) + const userInfo = await this.getUserInfo(userId) + if (!userInfo) { + throw new Error(`User info not found for ID: ${userId}`) + } + + // 2. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등) const contractInfo = await this.getContractInfo(projectId) if (!contractInfo) { throw new Error(`Contract info not found for ID: ${projectId}`) } - // 2. 업로드할 리비전 정보 조회 + // 3. 업로드할 리비전 정보 조회 const revisionsToUpload = await this.getRevisionsForUpload(revisionIds) if (revisionsToUpload.length === 0) { return { @@ -138,7 +194,7 @@ class DOLCEUploadService { } let uploadedDocuments = 0 - let uploadedFiles = 0 + const uploadedFiles = 0 const errors: string[] = [] const results: any = { documentResults: [], @@ -146,19 +202,19 @@ class DOLCEUploadService { mappingResults: [] } - // 3. 각 리비전별로 처리 + // 4. 각 리비전별로 처리 for (const revision of revisionsToUpload) { try { console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`) - // 3-1. UploadId 미리 생성 (파일이 있는 경우에만) + // 4-1. UploadId 미리 생성 (파일이 있는 경우에만) let uploadId: string | undefined if (revision.attachments && revision.attachments.length > 0) { uploadId = uuidv4() // 문서 업로드 시 사용할 UploadId 미리 생성 console.log(`Generated UploadId for document upload: ${uploadId}`) } - // 3-2. 문서 정보 업로드 (UploadId 포함) + // 4-2. 문서 정보 업로드 (UploadId 포함) const dolceDoc = this.transformToDoLCEDocument( revision, contractInfo, @@ -166,7 +222,13 @@ class DOLCEUploadService { contractInfo.vendorCode, ) - const docResult = await this.uploadDocument([dolceDoc], userId) + const docResult = await this.uploadDocument( + [dolceDoc], + userInfo.userId, + userInfo.userName, + userInfo.vendorCode, + userInfo.userEmail + ) if (!docResult.success) { errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`) continue // 문서 업로드 실패 시 다음 리비전으로 넘어감 @@ -176,11 +238,11 @@ class DOLCEUploadService { results.documentResults.push(docResult) console.log(`✅ Document uploaded successfully: ${revision.documentNo}`) - // 3-3. 파일 업로드 (이미 생성된 UploadId 사용) + // 4-3. 파일 업로드 (이미 생성된 UploadId 사용) if (uploadId && revision.attachments && revision.attachments.length > 0) { try { // 파일 업로드 시 이미 생성된 UploadId 사용 - const fileUploadResults = await this.uploadFiles( + await this.uploadFiles( revision.attachments, userId, uploadId // 이미 생성된 UploadId 전달 @@ -192,7 +254,7 @@ class DOLCEUploadService { } } - // 3-5. 성공한 리비전의 상태 업데이트 + // 4-4. 성공한 리비전의 상태 업데이트 await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId) } catch (error) { @@ -245,6 +307,41 @@ class DOLCEUploadService { : null } + /** + * 사용자 정보 조회 (DOLCE 업로드에 필요한 정보) + */ + private async getUserInfo(userId: string): Promise<{ + userId: string; + userName: string; + userEmail: string; + vendorCode: string; + } | null> { + const { users, vendors } = await import("@/db/schema") + + const [result] = await db + .select({ + userId: users.id, + userName: users.name, + userEmail: users.email, + vendorCode: vendors.vendorCode + }) + .from(users) + .innerJoin(vendors, eq(users.companyId, vendors.id)) + .where(eq(users.id, Number(userId))) + .limit(1) + + if (!result) { + return null + } + + return { + userId: String(result.userId), + userName: result.userName, + userEmail: result.userEmail, + vendorCode: result.vendorCode || "" + } + } + /** * 각 issueStageId별로 첫 번째 revision 정보를 조회 @@ -272,7 +369,7 @@ class DOLCEUploadService { /** * 업로드할 리비전 정보 조회 (문서 정보 및 첨부파일 포함) */ - private async getRevisionsForUpload(revisionIds: number[]) { + private async getRevisionsForUpload(revisionIds: number[]): Promise<RevisionWithAttachments[]> { // revisions → issueStages → documents 순서로 join하여 정보 조회 const revisionResults = await db .select({ @@ -333,7 +430,7 @@ class DOLCEUploadService { .where(inArray(revisions.id, revisionIds)) // 각 리비전의 첨부파일 정보도 조회 - const revisionsWithAttachments = [] + const revisionsWithAttachments: RevisionWithAttachments[] = [] for (const revision of revisionResults) { const attachments = await db .select({ @@ -365,11 +462,11 @@ class DOLCEUploadService { * @param uploadId 이미 생성된 UploadId (문서 업로드 시 생성됨) */ private async uploadFiles( - attachments: any[], + attachments: RevisionAttachment[], userId: string, uploadId: string // 이미 생성된 UploadId를 매개변수로 받음 ): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> { - const uploadResults = [] + const uploadResults: Array<{ uploadId: string, fileId: string, filePath: string }> = [] const resultDataArray: ResultData[] = [] for (let i = 0; i < attachments.length; i++) { @@ -516,7 +613,7 @@ class DOLCEUploadService { private async getFileStats(filePath: string): Promise<{ size: number, birthtime: Date, mtime: Date }> { try { // Node.js 환경이라면 fs.stat 사용 - const fs = require('fs').promises + const fs = await import('fs/promises') const stats = await fs.stat(filePath) return { @@ -524,7 +621,7 @@ class DOLCEUploadService { birthtime: stats.birthtime, mtime: stats.mtime } - } catch (error) { + } catch { console.warn(`Could not get file stats for ${filePath}, using defaults`) // 파일 정보를 가져올 수 없는 경우 기본값 사용 const now = new Date() @@ -539,13 +636,22 @@ class DOLCEUploadService { /** * 문서 정보 업로드 (DetailDwgReceiptMgmtEdit) */ - private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{ success: boolean, error?: string, data?: any }> { + private async uploadDocument( + dwgList: DOLCEDocument[], + userId: string, + userName: string, + vendorCode: string, + email: string + ): Promise<{ success: boolean, error?: string, data?: any }> { try { const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/DetailDwgReceiptMgmtEdit` const requestBody = { DwgList: dwgList, - UserID: userId + UserID: userId, + UserNM: userName, + VENDORCODE: vendorCode, + EMAIL: email } console.log('Uploading documents to DOLCE:', JSON.stringify(requestBody, null, 2)) @@ -623,12 +729,9 @@ class DOLCEUploadService { /** * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) */ - /** - * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) - */ private transformToDoLCEDocument( - revision: any, - contractInfo: any, + revision: RevisionWithAttachments, + contractInfo: { projectCode: string; vendorCode: string }, uploadId?: string, vendorCode?: string, ): DOLCEDocument { @@ -750,7 +853,7 @@ class DOLCEUploadService { Mode: mode, // Status: revision.revisionStatus || "Standby", Status: "Standby", - RegisterId: revision.registerId || 0, // registerId가 없으면 0 (ADD 모드) + RegisterId: revision.registerId ? (typeof revision.registerId === 'string' ? parseInt(revision.registerId) : revision.registerId) : 0, // registerId가 없으면 0 (ADD 모드) ProjectNo: contractInfo.projectCode, Discipline: revision.discipline || "DL", DrawingKind: revision.drawingKind || "B3", @@ -772,8 +875,8 @@ class DOLCEUploadService { * 파일 매핑 데이터 변환 */ private transformToFileMapping( - revision: any, - contractInfo: any, + revision: RevisionWithAttachments, + contractInfo: { projectCode: string; vendorCode: string }, uploadId: string, fileName: string ): DOLCEFileMapping { @@ -1008,8 +1111,7 @@ export const dolceUploadService = new DOLCEUploadService() export async function uploadRevisionsToDOLCE( projectId: number, revisionIds: number[], - userId: string, - userName?: string + userId: string ): Promise<DOLCEUploadResult> { - return dolceUploadService.uploadToDoLCE(projectId, revisionIds, userId, userName) + return dolceUploadService.uploadToDoLCE(projectId, revisionIds, userId) }
\ No newline at end of file |
