summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-11 17:49:09 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-11 17:49:09 +0900
commit88b737a71372353e47c466553273d88f5bf36834 (patch)
treea5d2221fac72392a26a1ffdf587ffc2fbc60b027 /lib/rfq-last
parente8e1d9c53580d546e400923d76e6c4a5ef96bd76 (diff)
(김준회) SOAP: PO생성요청시 미구현 부분 처리, PO 수신시 LOEKZ(삭제지시자)있는 PR은 스킵하도록 변경
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/contract-actions.ts131
1 files changed, 102 insertions, 29 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);