summaryrefslogtreecommitdiff
path: root/lib/rfq-last/contract-actions.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/contract-actions.ts')
-rw-r--r--lib/rfq-last/contract-actions.ts209
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;
}
}