diff options
Diffstat (limited to 'lib/rfq-last/contract-actions.ts')
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 209 |
1 files changed, 134 insertions, 75 deletions
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; } } |
