diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 131 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/po-mapper.ts | 26 |
2 files changed, 122 insertions, 35 deletions
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index e717c815..c09b296b 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -3,7 +3,8 @@ import db from "@/db/db"; import { rfqsLast, rfqLastDetails,rfqPrItems, prItemsForBidding,biddingConditions,biddingCompanies, projects, - biddings,generalContracts ,generalContractItems, vendors} from "@/db/schema"; + biddings,generalContracts ,generalContractItems, vendors, + rfqLastVendorResponses, rfqLastVendorQuotationItems} from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth/next" @@ -41,7 +42,7 @@ export async function createPO(params: CreatePOParams) { throw new Error("RFQ 정보를 찾을 수 없습니다."); } - // 2. 선정된 업체 확인 및 상세 정보 조회 + // 2. 선정된 업체 확인 및 상세 정보 조회 (벤더 국가 정보 포함) const [selectedVendor] = await db .select({ detail: rfqLastDetails, @@ -65,6 +66,10 @@ export async function createPO(params: CreatePOParams) { const vendorData = selectedVendor.vendor; const detailData = selectedVendor.detail; + // 벤더 국가 코드 확인 (SAP LANDS 필드용) + // SAP 국가 코드는 2자리 (KR, US, CN 등) + const vendorCountryCode = vendorData.country?.substring(0, 2).toUpperCase() || 'KR'; + // 3. PR 아이템 정보 조회 const prItems = await db .select() @@ -75,6 +80,37 @@ export async function createPO(params: CreatePOParams) { throw new Error("PR 아이템 정보를 찾을 수 없습니다."); } + // 3-1. 선정된 업체의 최신 견적 응답 조회 + const [vendorResponse] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, params.rfqId), + eq(rfqLastVendorResponses.vendorId, params.vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + if (!vendorResponse) { + throw new Error("선정된 업체의 견적 응답을 찾을 수 없습니다."); + } + + // 3-2. 견적 아이템별 가격 정보 조회 + const quotationItems = await db + .select() + .from(rfqLastVendorQuotationItems) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, vendorResponse.id)); + + if (quotationItems.length === 0) { + throw new Error("견적 아이템 정보를 찾을 수 없습니다."); + } + + // 3-3. PR 아이템 ID로 견적 아이템 매핑 생성 + const quotationItemMap = new Map( + quotationItems.map(item => [item.rfqPrItemId, item]) + ); + // 4. 필수 필드 검증 if (!vendorData.vendorCode) { throw new Error(`벤더 코드가 없습니다. (Vendor ID: ${vendorData.id})`); @@ -90,6 +126,11 @@ export async function createPO(params: CreatePOParams) { throw new Error("인코텀즈(Incoterms)가 설정되지 않았습니다."); } const incotermsCode = detailData.incotermsCode; // 타입 좁히기 + + if (!detailData.incotermsDetail) { + throw new Error("인코텀즈 상세 정보(INCO2)가 설정되지 않았습니다."); + } + const incotermsDetail = detailData.incotermsDetail; // 타입 좁히기 if (!detailData.taxCode) { throw new Error("세금코드(Tax Code)가 설정되지 않았습니다."); @@ -110,66 +151,98 @@ export async function createPO(params: CreatePOParams) { // 5. PO 데이터 구성 const poData = { T_Bidding_HEADER: [{ + // 필수 필드 ANFNR: anfnr, LIFNR: vendorCode, - ZPROC_IND: 'A', // TODO: 구매 처리 상태 - 의미 확인 필요 + ZPROC_IND: '9', // 구매 처리 상태: 9 (기존 로그 기준) WAERS: currency, ZTERM: paymentTermsCode, INCO1: incotermsCode, - INCO2: detailData.incotermsDetail || detailData.placeOfDestination || detailData.placeOfShipping || '', + INCO2: incotermsDetail.substring(0, 28), // SAP 최대 28자리 제한 MWSKZ: taxCode, - LANDS: 'KR', + LANDS: vendorCountryCode, // 벤더 국가 코드 사용 ZRCV_DT: getCurrentSAPDate(), - ZATTEN_IND: 'Y', + ZATTEN_IND: 'N', // 참석 여부: N (기본값, 실제 데이터 없음) IHRAN: getCurrentSAPDate(), - TEXT: `PO from RFQ: ${rfqData.rfqTitle || rfqData.itemName || ''}`, - ZDLV_CNTLR: rfqData.picCode || undefined, - ZDLV_PRICE_T: detailData.materialPriceRelatedYn ? 'Y' : 'N', - ZDLV_PRICE_NOTE: detailData.materialPriceRelatedYn ? '연동제 적용' : undefined, + + // Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지) + TEXT: rfqData.rfqTitle || rfqData.itemName || '', // PO Header note + ZDLV_CNTLR: rfqData.picCode || '', // Delivery Controller + ZDLV_PRICE_T: detailData.materialPriceRelatedYn ? 'Y' : 'N', // 납품대금연동제대상여부 + ZDLV_PRICE_NOTE: detailData.materialPriceRelatedYn ? '연동제 적용' : '', // 연동제 노트 + VSTEL: '', // Shipping Point (데이터 없음) + LSTEL: '', // Loading Point (데이터 없음) }], T_Bidding_ITEM: prItems.map((item, index) => { if (!item.uom) { throw new Error(`PR 아이템 ${index + 1}번의 단위(UOM)가 없습니다.`); } - // TODO: 아이템별 단가 및 금액 정보를 견적 응답(rfqLastVendorResponseItems)에서 가져와야 함 - // 현재는 총액만 받아서 계산할 수 없음 - // - NETPR: 아이템별 단가 (견적서의 unitPrice) - // - PEINH: 가격 단위 (견적서의 priceUnit 또는 기본값 확인 필요) - // - NETWR: 아이템별 순액 (quantity * unitPrice) - // - BRTWR: 아이템별 총액 (NETWR + 세금, 세율은 MWSKZ에 따라 다름) + // 견적 아이템에서 실제 가격 정보 가져오기 + const quoteItem = quotationItemMap.get(item.id); + if (!quoteItem) { + throw new Error(`PR 아이템 ${item.id}에 대한 견적 정보를 찾을 수 없습니다.`); + } + + // 가격 계산: SAP은 소수점을 포함한 문자열 형태로 받음 + const unitPrice = quoteItem.unitPrice || 0; + const quantity = quoteItem.quantity || item.quantity || 0; + const totalPrice = quoteItem.totalPrice || (unitPrice * quantity); + + // 납기일 계산 (우선순위: 견적 납기일 > PR 납기일 > 현재일자) + let deliveryDate = getCurrentSAPDate(); + 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, ''); + } return { + // 필수 필드 ANFNR: anfnr, - ANFPS: (index + 1).toString().padStart(5, '0'), + ANFPS: item.prItem || (index + 1).toString().padStart(5, '0'), // PR Item Number 사용 LIFNR: vendorCode, - NETPR: '0', // TODO: 견적서에서 실제 단가 가져오기 - PEINH: '1', // TODO: 가격 단위 확인 필요 + NETPR: unitPrice.toFixed(2), // 단가 (소수점 2자리) + PEINH: '1', // 가격 단위: 1 (표준값, 1단위당 가격) BPRME: item.uom, - NETWR: '0', // TODO: 견적서에서 실제 순액 가져오기 - BRTWR: '0', // TODO: 견적서에서 실제 총액(세금 포함) 가져오기 - LFDAT: item.deliveryDate ? new Date(item.deliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(), + NETWR: totalPrice.toFixed(2), // 순액 (세금 제외) + BRTWR: totalPrice.toFixed(2), // 총액: SAP이 taxCode(MWSKZ)로 세금 계산하도록 순액과 동일하게 전송 + LFDAT: deliveryDate, + + // Optional 필드 (명시적으로 포함 - 유지보수를 위해 구조 유지) + ZCON_NO_PO: item.prNo || '', // PR Consolidation Number + EBELP: '', // Series PO Item Seq (시리즈 PO가 아니면 빈 값) }; }), - T_PR_RETURN: [{ + T_PR_RETURN: prItems.map((item, index) => ({ + // 필수 필드 ANFNR: anfnr, - ANFPS: '00001', - EBELN: rfqData.prNumber || '', - EBELP: '00001', - MSGTY: 'S', - MSGTXT: 'Success' - }] + 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 + })) }; console.log('📤 SAP으로 PO 전송 시작:', { ANFNR: anfnr, LIFNR: vendorCode, vendorName: vendorData.vendorName, + vendorCountry: vendorCountryCode, itemCount: prItems.length, + quotationItemCount: quotationItems.length, totalAmount: params.totalAmount, currency: currency, + taxCode: taxCode, + incoterms: `${incotermsCode} - ${incotermsDetail}`, }); + // 디버깅: 전송 데이터 전체 로그 (서버 측 로그이므로 모든 정보 포함) + console.log('📦 PO 전송 데이터 (전체):', JSON.stringify(poData, null, 2)); + // 6. SAP SOAP 요청 전송 const sapResult = await createPurchaseOrder(poData); diff --git a/lib/soap/ecc/mapper/po-mapper.ts b/lib/soap/ecc/mapper/po-mapper.ts index 847ffdc0..4f8b8034 100644 --- a/lib/soap/ecc/mapper/po-mapper.ts +++ b/lib/soap/ecc/mapper/po-mapper.ts @@ -402,16 +402,30 @@ export async function mapAndSaveECCPOData( const contractItemsData: ContractItemData[] = []; for (const detail of details) { + // LOEKZ(삭제지시자)가 null이 아니면 스킵 + if (detail.LOEKZ !== null && detail.LOEKZ !== undefined && detail.LOEKZ !== '') { + debugLog('삭제 지시된 아이템 스킵', { + ebeln: detail.EBELN, + ebelp: detail.EBELP, + loekz: detail.LOEKZ + }); + continue; + } + const itemData = await mapECCPODetailToBusiness(detail, contractId); contractItemsData.push(itemData); } - // 일괄 삽입 - await tx.insert(contractItems).values(contractItemsData); - debugLog('계약 아이템 저장 완료', { - contractId, - itemCount: contractItemsData.length - }); + // 일괄 삽입 (유효한 아이템이 있을 때만) + if (contractItemsData.length > 0) { + await tx.insert(contractItems).values(contractItemsData); + debugLog('계약 아이템 저장 완료', { + contractId, + itemCount: contractItemsData.length + }); + } else { + debugLog('저장할 유효한 계약 아이템 없음', { contractId }); + } } processedCount++; |
