"use server"; import db from "@/db/db"; import { rfqsLast, rfqLastDetails,rfqPrItems, prItemsForBidding,biddingConditions,biddingCompanies, projects, 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" import { authOptions } from "@/app/api/auth/[...nextauth]/route" 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"; // ===== PO (SAP) 생성 ===== interface CreatePOParams { rfqId: number; vendorId: number; vendorName: string; totalAmount: number; currency: string; selectionReason?: string; } export async function createPO(params: CreatePOParams) { try { const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다."); } const userId = session.user.id; // 1. RFQ 정보 조회 const [rfqData] = await db .select() .from(rfqsLast) .where(eq(rfqsLast.id, params.rfqId)); if (!rfqData) { throw new Error("RFQ 정보를 찾을 수 없습니다."); } // 2. 선정된 업체 확인 및 상세 정보 조회 (벤더 국가 정보 포함) const [selectedVendor] = await db .select({ detail: rfqLastDetails, vendor: vendors, }) .from(rfqLastDetails) .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) .where( and( eq(rfqLastDetails.rfqsLastId, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); if (!selectedVendor || !selectedVendor.vendor) { throw new Error("선정된 업체 정보를 찾을 수 없습니다."); } 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() .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); if (prItems.length === 0) { 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) { console.warn(`⚠️ 벤더 코드가 없습니다. (Vendor ID: ${vendorData.id}) - 빈 값으로 전송합니다.`); } const vendorCode = vendorData.vendorCode || ''; // 빈 값으로 기본값 설정 if (!detailData.paymentTermsCode) { console.warn("⚠️ 지급조건(Payment Terms)이 설정되지 않았습니다. - 빈 값으로 전송합니다."); } const paymentTermsCode = detailData.paymentTermsCode || ''; // 빈 값으로 기본값 설정 if (!detailData.incotermsCode) { console.warn("⚠️ 인코텀즈(Incoterms)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); } const incotermsCode = detailData.incotermsCode || ''; // 빈 값으로 기본값 설정 if (!detailData.incotermsDetail) { console.warn("⚠️ 인코텀즈 상세 정보(INCO2)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); } const incotermsDetail = detailData.incotermsDetail || ''; // 빈 값으로 기본값 설정 if (!detailData.taxCode) { console.warn("⚠️ 세금코드(Tax Code)가 설정되지 않았습니다. - 빈 값으로 전송합니다."); } const taxCode = detailData.taxCode || ''; // 빈 값으로 기본값 설정 if (!detailData.currency && !params.currency) { console.warn("⚠️ 통화(Currency)가 설정되지 않았습니다. - KRW로 기본 설정합니다."); } 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)가 없습니다. - 빈 값으로 전송합니다."); } // 5. PO 데이터 구성 const poData = { T_Bidding_HEADER: [{ // 필수 필드 ANFNR: anfnr, LIFNR: vendorCode, ZPROC_IND: '9', // 구매 처리 상태: 9 (기존 로그 기준) WAERS: currency, ZTERM: paymentTermsCode, INCO1: incotermsCode, INCO2: incotermsDetail.substring(0, 28), // SAP 최대 28자리 제한 MWSKZ: taxCode, LANDS: vendorCountryCode, // 벤더 국가 코드 사용 ZRCV_DT: getCurrentSAPDate(), ZATTEN_IND: 'N', // 참석 여부: N (기본값, 실제 데이터 없음) IHRAN: getCurrentSAPDate(), // 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) { console.warn(`⚠️ PR 아이템 ${index + 1}번의 단위(UOM)가 없습니다. - 빈 값으로 전송합니다.`); } // 견적 아이템에서 실제 가격 정보 가져오기 const quoteItem = quotationItemMap.get(item.id); if (!quoteItem) { console.warn(`⚠️ 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: item.prItem || (index + 1).toString().padStart(5, '0'), // PR Item Number 사용 LIFNR: vendorCode, NETPR: unitPrice.toFixed(2), // 단가 (소수점 2자리) PEINH: '1', // 가격 단위: 1 (표준값, 1단위당 가격) BPRME: item.uom || '', 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: 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 })) }; 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); if (!sapResult.success) { throw new Error(`SAP PO 생성 실패: ${sapResult.message}`); } console.log('✅ SAP PO 전송 성공:', sapResult); // 7. 실제 PO 번호 추출 (SOAP 응답에서 추출하거나 ANFNR 사용) const actualPoNumber = sapResult.bidding_number || anfnr; // 8. DB에 실제 PO 번호 저장 및 RFQ 상태 업데이트 await db.transaction(async (tx) => { // rfqLastDetails 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "진행중", contractCreatedAt: new Date(), contractNo: actualPoNumber, updatedAt: new Date(), updatedBy: userId, }) .where( and( eq(rfqLastDetails.rfqsLastId, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true) ) ); // RFQ 상태 업데이트 await tx .update(rfqsLast) .set({ status: "최종업체선정", // PO 생성 완료 상태 updatedAt: new Date(), updatedBy: userId, }) .where(eq(rfqsLast.id, params.rfqId)); }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); return { success: true, message: "PO가 성공적으로 생성되어 SAP로 전송되었습니다.", poNumber: actualPoNumber, }; } catch (error) { console.error("❌ PO 생성 오류:", error); return { success: false, error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다." }; } } interface CreateGeneralContractParams { rfqId: number; vendorId: number; vendorName: string; totalAmount: number; currency: string; contractType: string; // 계약종류 추가 (UP, LE, IL 등) contractStartDate?: Date; contractEndDate?: Date; contractTerms?: string; } export async function createGeneralContract(params: CreateGeneralContractParams) { try { const session = await getServerSession(authOptions) if (!session?.user) { throw new Error("인증이 필요합니다.") } const userId = session.user.id; // 1. RFQ 정보 및 선정된 업체 확인 const [rfqData] = await db .select({ rfq: rfqsLast, vendor: rfqLastDetails, project: projects, }) .from(rfqsLast) .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); if (!rfqData || !rfqData.vendor) { throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } // 2. PR 아이템 정보 조회 (계약 아이템으로 변환용) const prItems = await db .select() .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); // 3. 계약번호 생성 - generateContractNumber 함수 사용 // 매개변수 순서: (userId, contractType) const contractNumber = await generateContractNumber( rfqData.rfq.picCode || undefined, // 발주담당자 코드 (userId) params.contractType // 계약종류 (UP, LE, IL 등) ); // 4. 트랜잭션으로 계약 생성 const result = await db.transaction(async (tx) => { // 4-1. generalContracts 테이블에 계약 생성 const [newContract] = await tx .insert(generalContracts) .values({ contractNumber, contractSourceType: 'estimate', // 견적에서 넘어온 계약 status: 'Draft', // 초안 상태로 시작 category: '일반계약', type: params.contractType, // 계약종류 저장 (UP, LE, IL 등) executionMethod: '일반계약', name: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 일반계약`, selectionMethod: '견적', // 업체 정보 vendorId: params.vendorId, // 계약 기간 startDate: params.contractStartDate || new Date(), endDate: params.contractEndDate || null, // 연계 정보 linkedRfqOrItb: rfqData.rfq.rfqCode || undefined, // 금액 정보 contractAmount: params.totalAmount, currency: params.currency || 'KRW', totalAmount: params.totalAmount, // 조건 정보 (RFQ에서 가져옴) contractCurrency: rfqData.vendor.currency || 'KRW', paymentTerm: rfqData.vendor.paymentTermsCode || undefined, taxType: rfqData.vendor.taxCode || undefined, deliveryTerm: rfqData.vendor.incotermsCode || undefined, shippingLocation: rfqData.vendor.placeOfShipping || undefined, dischargeLocation: rfqData.vendor.placeOfDestination || undefined, contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, // 연동제 적용 여부 interlockingSystem: rfqData.vendor.materialPriceRelatedYn ? 'Y' : 'N', // 시스템 정보 registeredById: userId, registeredAt: new Date(), lastUpdatedById: userId, lastUpdatedAt: new Date(), notes: params.contractTerms || rfqData.vendor.remark || undefined, }) .returning(); // 4-2. generalContractItems 테이블에 아이템 생성 if (prItems.length > 0) { const contractItems = prItems.map(item => ({ contractId: newContract.id, project: rfqData.project?.name || undefined, itemCode: item.materialCode || undefined, itemInfo: `${item.materialCategory || ''} / ${item.materialCode || ''}`, specification: item.materialDescription || undefined, quantity: item.quantity, quantityUnit: item.uom || undefined, contractDeliveryDate: item.deliveryDate || undefined, contractCurrency: params.currency || 'KRW', // 단가와 금액은 견적 데이터에서 가져와야 함 (현재는 총액을 아이템 수로 나눔) contractUnitPrice: params.totalAmount / prItems.length, contractAmount: params.totalAmount / prItems.length, })); await tx.insert(generalContractItems).values(contractItems); } // 4-3. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "일반계약 진행중", contractCreatedAt: new Date(), contractNo: contractNumber, updatedAt: new Date(), updatedBy: userId, }) .where( and( eq(rfqLastDetails.rfqsLastId, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true) ) ); return newContract; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); revalidatePath("/contracts"); return { success: true, message: "일반계약이 성공적으로 생성되었습니다.", contractNumber: result.contractNumber, contractId: result.id, }; } catch (error) { console.error("일반계약 생성 오류:", error); return { success: false, error: error instanceof Error ? error.message : "일반계약 생성 중 오류가 발생했습니다." }; } } // ===== 입찰 생성 ===== interface CreateBiddingParams { rfqId: number; vendorId: number; vendorName: string; totalAmount: number; currency: string; contractType: "unit_price" | "general" | "sale"; // 계약구분 biddingType: string; // 입찰유형 (equipment, construction 등) awardCount: "single" | "multiple"; // 낙찰수 biddingStartDate: Date; biddingEndDate: Date; biddingRequirements?: string; } export async function createBidding(params: CreateBiddingParams) { try { const session = await getServerSession(authOptions) if (!session?.user) { throw new Error("인증이 필요합니다.") } const userId = session.user.id; // 1. RFQ 정보 및 선정된 업체 확인 const [rfqData] = await db .select({ rfq: rfqsLast, vendor: rfqLastDetails, project: projects, }) .from(rfqsLast) .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); if (!rfqData || !rfqData.vendor) { throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } // 2. PR 아이템 정보 조회 const prItems = await db .select() .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); // 3. 트랜잭션으로 입찰 생성 const result = await db.transaction(async (tx) => { // 3-1. 입찰번호 생성 - generateBiddingNumber 함수 사용 const biddingNumber = await generateBiddingNumber( rfqData.rfq.picCode || undefined, // 발주담당자 코드 tx // 트랜잭션 컨텍스트 전달 ); // 3-2. biddings 테이블에 입찰 생성 const [newBidding] = await tx .insert(biddings) .values({ biddingNumber, revision: 0, biddingSourceType: 'estimate', projectId: rfqData.rfq.projectId || undefined, // 기본 정보 projectName: rfqData.project?.name || undefined, itemName: rfqData.rfq.itemName || undefined, title: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 입찰`, description: params.biddingRequirements || rfqData.rfq.remark || undefined, // 계약 정보 - 파라미터에서 받은 값 사용 contractType: params.contractType, biddingType: params.biddingType, awardCount: params.awardCount, // 일정 관리 - 파라미터에서 받은 날짜 사용 biddingRegistrationDate: new Date(), submissionStartDate: params.biddingStartDate, submissionEndDate: params.biddingEndDate, // 예산 및 가격 정보 currency: params.currency || 'KRW', budget: params.totalAmount, targetPrice: params.totalAmount, // PR 정보 prNumber: rfqData.rfq.prNumber || undefined, hasPrDocument: false, // 상태 status: 'bidding_generated', isPublic: false, // 다이얼로그에서 체크박스로 받을 수 있음 isUrgent: false, // 다이얼로그에서 체크박스로 받을 수 있음 // 담당자 정보 managerName: rfqData.rfq.picName || undefined, // 메타 정보 createdBy: String(userId), createdAt: new Date(), updatedAt: new Date(), updatedBy: String(userId), ANFNR: rfqData.rfq.ANFNR || undefined, }) .returning(); // 3-3. PR 아이템을 입찰 아이템으로 변환 if (prItems.length > 0) { const biddingPrItems = prItems.map(item => ({ biddingId: newBidding.id, itemNumber: item.rfqItem || undefined, projectInfo: rfqData.project?.name || undefined, // 프로젝트 이름 사용 itemInfo: item.materialDescription || undefined, requestedDeliveryDate: item.deliveryDate || undefined, currency: params.currency || 'KRW', quantity: item.quantity, quantityUnit: item.uom || undefined, totalWeight: item.grossWeight || undefined, weightUnit: item.gwUom || undefined, materialDescription: item.materialDescription || undefined, prNumber: item.prNo || undefined, hasSpecDocument: !!item.specUrl, })); await tx.insert(prItemsForBidding).values(biddingPrItems); } // 3-4. 선정된 업체를 입찰 참여 업체로 등록 await tx.insert(biddingCompanies).values({ biddingId: newBidding.id, companyId: params.vendorId, invitationStatus: 'pending', preQuoteAmount: params.totalAmount, // 견적 금액을 사전견적으로 사용 isPreQuoteSelected: true, // 본입찰 대상으로 자동 선정 isBiddingInvited: true, notes: '견적에서 선정된 업체', }); // 3-5. 입찰 조건 생성 (RFQ 조건 활용) await tx.insert(biddingConditions).values({ biddingId: newBidding.id, paymentTerms: JSON.stringify([rfqData.vendor.paymentTermsCode]), taxConditions: JSON.stringify([rfqData.vendor.taxCode]), contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, isPriceAdjustmentApplicable: rfqData.vendor.materialPriceRelatedYn || false, incoterms: JSON.stringify([rfqData.vendor.incotermsCode]), shippingPort: rfqData.vendor.placeOfShipping || undefined, destinationPort: rfqData.vendor.placeOfDestination || undefined, }); // 3-6. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "입찰진행중", contractCreatedAt: new Date(), contractNo: biddingNumber, updatedAt: new Date(), updatedBy: userId, }) .where( and( eq(rfqLastDetails.rfqsLastId, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true) ) ); return newBidding; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); revalidatePath("/biddings"); return { success: true, message: "입찰이 성공적으로 생성되었습니다.", biddingNumber: result.biddingNumber, biddingId: result.id, }; } catch (error) { console.error("입찰 생성 오류:", error); return { success: false, error: error instanceof Error ? error.message : "입찰 생성 중 오류가 발생했습니다." }; } } // ===== 계약 타입 확인 ===== export async function checkContractStatus(rfqId: number) { try { const [detail] = await db .select({ contractStatus: rfqLastDetails.contractStatus, contractNo: rfqLastDetails.contractNo, contractCreatedAt: rfqLastDetails.contractCreatedAt, }) .from(rfqLastDetails) .where( and( eq(rfqLastDetails.rfqsLastId, rfqId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); return { success: true, data: detail, hasContract: !!detail?.contractNo, }; } catch (error) { console.error("계약 상태 확인 오류:", error); return { success: false, error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다." }; } }