/** * 입찰초대 관련 결재 액션 핸들러 * * ✅ 베스트 프랙티스: * - 'use server' 지시어 없음 (순수 비즈니스 로직만) * - 결재 승인 후 실행될 최소한의 데이터만 처리 * - DB 조작 및 실제 비즈니스 로직만 포함 */ import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** * 입찰초대 핸들러 (결재 승인 후 실행됨) * * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) * * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) */ export async function requestBiddingInvitationInternal(payload: { biddingId: number; vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string; vendorEmail?: string | null; contactPerson?: string | null; contactEmail?: string | null; ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; biddingCompanyId: number; biddingId: number; }>; message?: string; currentUserId: number; // ✅ 결재 상신한 사용자 ID }) { debugLog('[BiddingInvitationHandler] 입찰초대 핸들러 시작', { biddingId: payload.biddingId, vendorCount: payload.vendors.length, currentUserId: payload.currentUserId, }); // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) if (!payload.currentUserId || payload.currentUserId <= 0) { const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; debugError('[BiddingInvitationHandler]', errorMessage); throw new Error(errorMessage); } try { // 1. 기본계약 발송 const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service'); const vendorDataForContract = payload.vendors.map(vendor => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode || undefined, vendorCountry: vendor.vendorCountry, selectedMainEmail: vendor.vendorEmail || '', additionalEmails: [], customEmails: [], contractRequirements: { ndaYn: vendor.ndaYn || false, generalGtcYn: vendor.generalGtcYn || false, projectGtcYn: vendor.projectGtcYn || false, agreementYn: vendor.agreementYn || false, }, biddingCompanyId: vendor.biddingCompanyId, biddingId: vendor.biddingId, hasExistingContracts: false, // 결재 후처리에서는 기존 계약 확인 생략 })); const contractResult = await sendBiddingBasicContracts( payload.biddingId, vendorDataForContract, [], // generatedPdfs - 결재 템플릿이므로 PDF는 빈 배열 payload.message ); if (!contractResult.success) { debugError('[BiddingInvitationHandler] 기본계약 발송 실패', contractResult.error); throw new Error(contractResult.error || '기본계약 발송에 실패했습니다.'); } debugLog('[BiddingInvitationHandler] 기본계약 발송 완료'); // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경) const { registerBidding } = await import('@/lib/bidding/detail/service'); const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString()); if (!registerResult.success) { debugError('[BiddingInvitationHandler] 입찰 등록 실패', registerResult.error); throw new Error(registerResult.error || '입찰 등록에 실패했습니다.'); } debugSuccess('[BiddingInvitationHandler] 입찰초대 완료', { biddingId: payload.biddingId, vendorCount: payload.vendors.length, message: registerResult.message, }); return { success: true, biddingId: payload.biddingId, vendorCount: payload.vendors.length, message: `기본계약 발송 및 본입찰 초대가 완료되었습니다.`, }; } catch (error) { debugError('[BiddingInvitationHandler] 입찰초대 중 에러', error); throw error; } } /** * 입찰초대 데이터를 결재 템플릿 변수로 매핑 * * @param payload - 입찰초대 데이터 * @returns 템플릿 변수 객체 (Record) */ export async function mapBiddingInvitationToTemplateVariables(payload: { bidding: { id: number; title: string; biddingNumber: string; projectName?: string; itemName?: string; awardCount: string; biddingType: string; bidPicName?: string; supplyPicName?: string; submissionStartDate?: Date; submissionEndDate?: Date; hasSpecificationMeeting?: boolean; isUrgent?: boolean; remarks?: string; targetPrice?: number; }; biddingItems: Array<{ id: number; projectName?: string; materialGroup?: string; materialGroupName?: string; materialCode?: string; materialCodeName?: string; quantity?: number; purchasingUnit?: string; targetUnitPrice?: number; quantityUnit?: string; totalWeight?: number; weightUnit?: string; budget?: number; targetAmount?: number; currency?: string; }>; vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string; vendorEmail?: string | null; contactPerson?: string | null; contactEmail?: string | null; }>; message?: string; specificationMeeting?: { meetingDate: Date | string | null; meetingTime: string | null; location: string | null; address: string | null; contactPerson: string | null; contactPhone: string | null; contactEmail: string | null; agenda: string | null; materials: string | null; notes: string | null; }; requestedAt: Date; }): Promise> { const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 const title = bidding.title || ''; // 입찰명 const biddingTitle = bidding.title || ''; // 입찰번호 const biddingNumber = bidding.biddingNumber || ''; // 낙찰업체수 const awardCount = bidding.awardCount || ''; // 계약구분 const contractType = bidding.biddingType || ''; // P/R번호 - bidding 테이블에 없으므로 빈 값 const prNumber = ''; // 예산 const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; // 내정가 const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; // 입찰요청 시스템 const requestSystem = 'eVCP'; // 입찰담당자 const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; // 내정가 산정 기준 - bidding 테이블에 없으므로 빈 값 const targetPriceBasis = ''; // 입찰 개요 const biddingOverview = bidding.itemName || message || ''; // 입찰 공고문 const biddingNotice = message || ''; // 협력사 정보들 const vendorVariables: Record = {}; vendors.forEach((vendor, index) => { const num = index + 1; vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || ''; vendorVariables[`협력사명_${num}`] = vendor.vendorName || ''; vendorVariables[`담당자_${num}`] = vendor.contactPerson || ''; vendorVariables[`이메일_${num}`] = vendor.contactEmail || vendor.vendorEmail || ''; vendorVariables[`전화번호_${num}`] = ''; // 연락처 정보가 없으므로 빈 값 }); // 사양설명회 정보 const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; // 입찰서제출기간 정보 const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 const submissionPeriodStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const submissionPeriodEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; // 대상 자재 수 const targetMaterialCount = biddingItems.length.toString(); // 자재 정보들 const materialVariables: Record = {}; biddingItems.forEach((item, index) => { const num = index + 1; materialVariables[`프로젝트_${num}`] = item.projectName || ''; materialVariables[`자재그룹_${num}`] = item.materialGroup || ''; materialVariables[`자재그룹명_${num}`] = item.materialGroupName || ''; materialVariables[`자재코드_${num}`] = item.materialCode || ''; materialVariables[`자재코드명_${num}`] = item.materialCodeName || ''; materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; materialVariables[`구매단위_${num}`] = item.purchasingUnit || ''; materialVariables[`내정단가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; materialVariables[`수량단위_${num}`] = item.quantityUnit || ''; materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : ''; materialVariables[`중량단위_${num}`] = item.weightUnit || ''; materialVariables[`예산_${num}`] = item.budget ? item.budget.toLocaleString() : ''; materialVariables[`내정금액_${num}`] = item.targetAmount ? item.targetAmount.toLocaleString() : ''; materialVariables[`통화_${num}`] = item.currency || ''; }); return { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, 낙찰업체수: awardCount, 계약구분: contractType, 'P/R번호': prNumber, 예산: budget, 내정가: targetPrice, 입찰요청_시스템: requestSystem, 입찰담당자: biddingManager, 내정가_산정_기준: targetPriceBasis, 입찰개요: biddingOverview, 입찰공고문: biddingNotice, ...vendorVariables, 사양설명회_실행여부: hasSpecMeeting, 사양설명회_시작예정일시: specMeetingStart, 사양설명회_종료예정일시: specMeetingEnd, 입찰서제출기간_실행여부: submissionPeriodExecution, 입찰서제출기간_시작예정일시: submissionPeriodStart, 입찰서제출기간_종료예정일시: submissionPeriodEnd, 대상_자재_수: targetMaterialCount, ...materialVariables, }; } /** * 폐찰 데이터를 결재 템플릿 변수로 매핑 * * @param payload - 폐찰 데이터 * @returns 템플릿 변수 객체 (Record) */ export async function mapBiddingClosureToTemplateVariables(payload: { bidding: { id: number; title: string; biddingNumber: string; projectName: string | null; itemName: string | null; biddingType: string; bidPicName: string | null; supplyPicName: string | null; targetPrice: string | null; }; biddingItems: Array<{ id: number; materialCode: string | null; materialCodeName: string | null; quantity: string | null; quantityUnit: string | null; targetUnitPrice: string | null; currency: string | null; }>; vendorSubmissions: Array<{ vendorId: number | null; vendorName: string | null; vendorCode: string | null; finalQuoteAmount: string | null; submitted: boolean | null; targetPrice: string | null; }>; description: string; requestedAt: Date; }): Promise> { const { bidding, biddingItems, vendorSubmissions, description, requestedAt } = payload; // 기본 정보 매핑 const title = bidding.title || '폐찰'; const biddingTitle = bidding.title || ''; const biddingNumber = bidding.biddingNumber || ''; const winnerCount = '1'; // 기본값 const contractType = bidding.biddingType || ''; const targetPriceNum = bidding.targetPrice ? parseFloat(bidding.targetPrice) : 0; const targetPrice = targetPriceNum ? targetPriceNum.toLocaleString() : ''; const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; const biddingOverview = bidding.itemName || ''; // 폐찰 사유 const closureReason = description; // 협력사별 입찰 현황 매핑 const vendorVariables: Record = {}; vendorSubmissions.filter(vendor => vendor.vendorId && vendor.vendorName).forEach((vendor, index) => { const num = index + 1; const targetPriceNum = vendor.targetPrice ? parseFloat(vendor.targetPrice) : 0; const finalQuoteAmountNum = vendor.finalQuoteAmount ? parseFloat(vendor.finalQuoteAmount) : 0; vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || ''; vendorVariables[`협력사명_${num}`] = vendor.vendorName || ''; vendorVariables[`응찰유무_${num}`] = vendor.submitted ? '응찰' : '미응찰'; vendorVariables[`내정가_${num}`] = targetPriceNum ? targetPriceNum.toLocaleString() : ''; vendorVariables[`입찰가_${num}`] = finalQuoteAmountNum ? finalQuoteAmountNum.toLocaleString() : ''; vendorVariables[`비율_${num}`] = (targetPriceNum && finalQuoteAmountNum && targetPriceNum > 0) ? ((finalQuoteAmountNum / targetPriceNum) * 100).toFixed(2) + '%' : ''; }); // 품목별 입찰 정보 매핑 (간소화 - 첫 번째 품목 기준으로 매핑) const materialVariables: Record = {}; biddingItems.forEach((item, index) => { const num = index + 1; const quantityNum = item.quantity ? parseFloat(item.quantity) : 0; const targetUnitPriceNum = item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : 0; materialVariables[`품목코드_${num}`] = item.materialCode || ''; materialVariables[`품목명_${num}`] = item.materialCodeName || ''; materialVariables[`수량_${num}`] = quantityNum ? quantityNum.toString() : ''; materialVariables[`단위_${num}`] = item.quantityUnit || ''; materialVariables[`통화_${num}`] = item.currency || ''; materialVariables[`내정가_${num}`] = targetUnitPriceNum ? targetUnitPriceNum.toString() : ''; }); return { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, 낙찰업체수: winnerCount, 계약구분: contractType, 내정가: targetPrice, 입찰담당자: biddingManager, 입찰개요: biddingOverview, 폐찰_사유: closureReason, ...vendorVariables, ...materialVariables, }; } /** * 폐찰 핸들러 (결재 승인 후 실행됨) * * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) * * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) */ export async function requestBiddingClosureInternal(payload: { biddingId: number; description: string; files?: File[]; currentUserId: number; // ✅ 결재 상신한 사용자 ID }) { debugLog('[BiddingClosureHandler] 폐찰 핸들러 시작', { biddingId: payload.biddingId, description: payload.description, currentUserId: payload.currentUserId, }); // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) if (!payload.currentUserId || payload.currentUserId <= 0) { const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; debugError('[BiddingClosureHandler]', errorMessage); throw new Error(errorMessage); } try { // 1. 입찰 상태를 폐찰로 변경 const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); const { getUserNameById } = await import('@/lib/bidding/actions'); const userName = await getUserNameById(payload.currentUserId.toString()); await db .update(biddings) .set({ status: 'bid_closure', updatedBy: userName, updatedAt: new Date(), remarks: payload.description, // 폐찰 사유를 remarks에 저장 }) .where(eq(biddings.id, payload.biddingId)); debugSuccess('[BiddingClosureHandler] 폐찰 완료', { biddingId: payload.biddingId, description: payload.description, }); // 4. 첨부파일들 저장 (evaluation_doc로 저장) if (payload.files && payload.files.length > 0) { const { saveFile } = await import('@/lib/file-stroage'); const { biddingDocuments } = await import('@/db/schema'); for (const file of payload.files) { try { const saveResult = await saveFile({ file, directory: `biddings/${payload.biddingId}/closure-documents`, originalName: file.name, userId: payload.currentUserId.toString() }) if (saveResult.success) { await db.insert(biddingDocuments).values({ biddingId: payload.biddingId, documentType: 'evaluation_doc', fileName: saveResult.fileName!, originalFileName: saveResult.originalName!, fileSize: saveResult.fileSize!, mimeType: file.type, filePath: saveResult.publicPath!, title: `폐찰 문서 - ${file.name}`, description: payload.description, isPublic: false, isRequired: false, uploadedBy: payload.currentUserId.toString(), }) } else { console.error(`Failed to save closure file: ${file.name}`, saveResult.error) } } catch (error) { console.error(`Error saving closure file: ${file.name}`, error) } } } return { success: true, biddingId: payload.biddingId, message: `입찰이 폐찰 처리되었습니다.`, }; } catch (error) { debugError('[BiddingClosureHandler] 폐찰 중 에러', error); throw error; } } /** * 낙찰 핸들러 (결재 승인 후 실행됨) * * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) * * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) */ export async function requestBiddingAwardInternal(payload: { biddingId: number; selectionReason: string; awardedCompanies: Array<{ companyId: number; companyName: string | null; finalQuoteAmount: number; awardRatio: number; }>; // ✅ 결재 상신 시점의 낙찰 대상 정보 currentUserId: number; // ✅ 결재 상신한 사용자 ID }) { debugLog('[BiddingAwardHandler] 낙찰 핸들러 시작', { biddingId: payload.biddingId, selectionReason: payload.selectionReason, currentUserId: payload.currentUserId, }); // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) if (!payload.currentUserId || payload.currentUserId <= 0) { const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; debugError('[BiddingAwardHandler]', errorMessage); throw new Error(errorMessage); } try { // ✅ 결재 상신 시점의 낙찰 대상 정보를 직접 사용하여 처리 const { default: db } = await import('@/db/db'); const { biddings, vendorSelectionResults } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); // 1. 최종입찰가 계산 (낙찰된 업체의 견적금액 * 발주비율의 합) let finalBidPrice = 0; for (const company of payload.awardedCompanies) { finalBidPrice += company.finalQuoteAmount * (company.awardRatio / 100); } await db.transaction(async (tx) => { // 1. 입찰 상태를 낙찰로 변경하고 최종입찰가 업데이트 await tx .update(biddings) .set({ status: 'vendor_selected', finalBidPrice: finalBidPrice.toString(), updatedAt: new Date() }) .where(eq(biddings.id, payload.biddingId)); // 2. 선정 사유 저장 (첫 번째 낙찰 업체 기준으로 저장) const firstAwardedCompany = payload.awardedCompanies[0]; // 기존 선정 결과 확인 const existingResult = await tx .select() .from(vendorSelectionResults) .where(eq(vendorSelectionResults.biddingId, payload.biddingId)) .limit(1); if (existingResult.length > 0) { // 업데이트 await tx .update(vendorSelectionResults) .set({ selectedCompanyId: firstAwardedCompany.companyId, selectionReason: payload.selectionReason, selectedBy: payload.currentUserId.toString(), selectedAt: new Date(), updatedAt: new Date() }) .where(eq(vendorSelectionResults.biddingId, payload.biddingId)); } else { // 삽입 await tx .insert(vendorSelectionResults) .values({ biddingId: payload.biddingId, selectedCompanyId: firstAwardedCompany.companyId, selectionReason: payload.selectionReason, selectedBy: payload.currentUserId.toString(), selectedAt: new Date(), createdAt: new Date(), updatedAt: new Date() }); } }); // 캐시 무효화 (API를 통한 방식) const { revalidateViaCronJob } = await import('@/lib/revalidation-utils'); await revalidateViaCronJob({ tags: [`bidding-${payload.biddingId}`, 'quotation-vendors', 'quotation-details'] }); debugSuccess('[BiddingAwardHandler] 낙찰 완료', { biddingId: payload.biddingId, selectionReason: payload.selectionReason, awardedCompaniesCount: payload.awardedCompanies.length, finalBidPrice, }); return { success: true, biddingId: payload.biddingId, message: `입찰이 낙찰 처리되었습니다. 최종입찰가: ${finalBidPrice.toLocaleString()}원`, }; } catch (error) { debugError('[BiddingAwardHandler] 낙찰 중 에러', error); throw error; } } /** * 낙찰 데이터를 결재 템플릿 변수로 매핑 * * @param payload - 낙찰 데이터 * @returns 템플릿 변수 객체 (Record) */ export async function mapBiddingAwardToTemplateVariables(payload: { biddingId: number; selectionReason: string; requestedAt: Date; awardedCompanies?: Array<{ companyId: number; companyName: string | null; finalQuoteAmount: number; awardRatio: number; vendorCode?: string | null; companySize?: string | null; targetPrice?: number | null; }>; }): Promise> { const { biddingId, selectionReason, requestedAt } = payload; // 1. 입찰 정보 조회 debugLog('[BiddingAwardMapper] 입찰 정보 조회 시작'); const { default: db } = await import('@/db/db'); const { biddings, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); const biddingInfo = await db .select({ id: biddings.id, title: biddings.title, biddingNumber: biddings.biddingNumber, projectName: biddings.projectName, itemName: biddings.itemName, biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, budget: biddings.budget, targetPrice: biddings.targetPrice, awardCount: biddings.awardCount, }) .from(biddings) .where(eq(biddings.id, biddingId)) .limit(1); if (biddingInfo.length === 0) { debugError('[BiddingAwardMapper] 입찰 정보를 찾을 수 없음'); throw new Error('입찰 정보를 찾을 수 없습니다'); } const bidding = biddingInfo[0]; // 2. 낙찰된 업체 정보 조회 let awardedCompanies = payload.awardedCompanies; if (!awardedCompanies) { const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); awardedCompanies = await getAwardedCompanies(biddingId); } // 3. 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db .select({ id: prItemsForBidding.id, materialNumber: prItemsForBidding.materialNumber, materialInfo: prItemsForBidding.materialInfo, priceUnit: prItemsForBidding.priceUnit, quantity: prItemsForBidding.quantity, quantityUnit: prItemsForBidding.quantityUnit, totalWeight: prItemsForBidding.totalWeight, weightUnit: prItemsForBidding.weightUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, currency: prItemsForBidding.targetCurrency, }) .from(prItemsForBidding) .where(eq(prItemsForBidding.biddingId, biddingId)); debugLog('[BiddingAwardMapper] 입찰 정보 조회 완료', { biddingId, itemCount: biddingItemsInfo.length, awardedCompanyCount: awardedCompanies.length, }); // 기본 정보 매핑 const title = bidding.title || '낙찰'; const biddingTitle = bidding.title || ''; const biddingNumber = bidding.biddingNumber || ''; const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString(); const contractType = bidding.biddingType || ''; const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; const biddingOverview = bidding.itemName || ''; // 업체 선정 사유 const selectionReasonMapped = selectionReason; // 낙찰된 업체 정보 매핑 const vendorVariables: Record = {}; awardedCompanies.forEach((company, index) => { const num = index + 1; vendorVariables[`협력사_코드_${num}`] = company.vendorCode || ''; vendorVariables[`협력사명_${num}`] = company.companyName || ''; vendorVariables[`기업규모_${num}`] = company.companySize || ''; // TODO: 기업규모 정보가 없으므로 빈 값 vendorVariables[`연동제희망여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발 vendorVariables[`연동제적용여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발 vendorVariables[`낙찰유무_${num}`] = '낙찰'; vendorVariables[`확정금액_${num}`] = (company.finalQuoteAmount * company.awardRatio / 100).toLocaleString(); vendorVariables[`내정액_${num}`] = company.targetPrice ? company.targetPrice.toLocaleString() : ''; vendorVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString(); vendorVariables[`비율_${num}`] = company.targetPrice && company.targetPrice > 0 ? ((company.finalQuoteAmount / company.targetPrice) * 100).toFixed(2) + '%' : ''; }); // 품목별 입찰 정보 매핑 const materialVariables: Record = {}; biddingItemsInfo.forEach((item, index) => { const num = index + 1; materialVariables[`자재번호_${num}`] = item.materialNumber || ''; materialVariables[`자재내역_${num}`] = item.materialInfo || ''; materialVariables[`구매단위_${num}`] = item.priceUnit || ''; materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; materialVariables[`수량단위_${num}`] = item.quantityUnit || ''; materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : ''; materialVariables[`중량단위_${num}`] = item.weightUnit || ''; materialVariables[`통화_${num}`] = item.currency || ''; materialVariables[`내정액_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; // 각 품목에 대한 낙찰 협력사 정보 (낙찰된 업체만 표시) awardedCompanies.forEach((company, companyIndex) => { const companyNum = companyIndex + 1; materialVariables[`협력사명_${num}`] = company.companyName || ''; materialVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString(); }); }); return { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, 낙찰업체수: winnerCount, 계약구분: contractType, 예산: budget, 내정액: targetPrice, 입찰담당자: biddingManager, 입찰개요: biddingOverview, 업체선정사유: selectionReasonMapped, 대상_자재_수: biddingItemsInfo.length.toString(), ...vendorVariables, ...materialVariables, }; }