diff options
Diffstat (limited to 'lib/bidding/handlers.ts')
| -rw-r--r-- | lib/bidding/handlers.ts | 429 |
1 files changed, 429 insertions, 0 deletions
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index fc2951d4..d55107c0 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -281,3 +281,432 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { ...materialVariables, }; } + +/** + * 폐찰 데이터를 결재 템플릿 변수로 매핑 + * + * @param payload - 폐찰 데이터 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapBiddingClosureToTemplateVariables(payload: { + biddingId: number; + description: string; + requestedAt: Date; +}): Promise<Record<string, string>> { + const { biddingId, description, requestedAt } = payload; + + // 1. 입찰 정보 조회 + debugLog('[BiddingClosureMapper] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, prItemsForBidding, biddingCompanies, biddingVendorSubmissions } = await import('@/db/schema'); + const { eq, leftJoin } = 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, + targetPrice: biddings.targetPrice, + winnerCount: biddings.winnerCount, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingClosureMapper] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 2. 입찰 대상 자재 정보 조회 + const biddingItemsInfo = await db + .select({ + id: prItemsForBidding.id, + materialCode: prItemsForBidding.materialNumber, + materialCodeName: prItemsForBidding.materialInfo, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + targetUnitPrice: prItemsForBidding.targetUnitPrice, + currency: prItemsForBidding.targetCurrency, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)); + + // 3. 입찰 참여 업체 및 제출 정보 조회 + const vendorSubmissions = await db + .select({ + vendorId: biddingCompanies.vendorId, + vendorName: biddingCompanies.vendorName, + vendorCode: biddingCompanies.vendorCode, + targetPrice: biddingVendorSubmissions.targetPrice, + bidPrice: biddingVendorSubmissions.bidPrice, + submitted: biddingVendorSubmissions.submitted, + }) + .from(biddingCompanies) + .leftJoin(biddingVendorSubmissions, eq(biddingCompanies.id, biddingVendorSubmissions.biddingCompanyId)) + .where(eq(biddingCompanies.biddingId, biddingId)); + + debugLog('[BiddingClosureMapper] 입찰 정보 조회 완료', { + biddingId, + itemCount: biddingItemsInfo.length, + vendorCount: vendorSubmissions.length, + }); + + // 기본 정보 매핑 + const title = bidding.title || '폐찰'; + const biddingTitle = bidding.title || ''; + const biddingNumber = bidding.biddingNumber || ''; + const winnerCount = (bidding.winnerCount || 1).toString(); + const contractType = bidding.biddingType || ''; + const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; + const biddingOverview = bidding.itemName || ''; + + // 폐찰 사유 + const closureReason = description; + + // 협력사별 입찰 현황 매핑 + const vendorVariables: Record<string, string> = {}; + vendorSubmissions.forEach((vendor, index) => { + const num = index + 1; + vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || ''; + vendorVariables[`협력사명_${num}`] = vendor.vendorName || ''; + vendorVariables[`응찰유무_${num}`] = vendor.submitted ? '응찰' : '미응찰'; + vendorVariables[`내정가_${num}`] = vendor.targetPrice ? vendor.targetPrice.toLocaleString() : ''; + vendorVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : ''; + vendorVariables[`비율_${num}`] = (vendor.targetPrice && vendor.bidPrice && vendor.targetPrice > 0) + ? ((vendor.bidPrice / vendor.targetPrice) * 100).toFixed(2) + '%' + : ''; + }); + + // 품목별 입찰 정보 매핑 (간소화 - 첫 번째 품목 기준으로 매핑) + const materialVariables: Record<string, string> = {}; + biddingItemsInfo.forEach((item, index) => { + const num = index + 1; + materialVariables[`품목코드_${num}`] = item.materialCode || ''; + materialVariables[`품목명_${num}`] = item.materialCodeName || ''; + materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; + materialVariables[`단위_${num}`] = item.quantityUnit || ''; + materialVariables[`통화_${num}`] = item.currency || ''; + materialVariables[`내정가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; + + // 각 품목에 대한 협력사별 입찰가 (간소화: 동일 품목에 대한 모든 업체 입찰가 표시) + vendorSubmissions.forEach((vendor, vendorIndex) => { + const vendorNum = vendorIndex + 1; + materialVariables[`협력사코드_${num}`] = vendor.vendorCode || ''; + materialVariables[`협력사명_${num}`] = vendor.vendorName || ''; + materialVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : ''; + }); + }); + + 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'); + + await db + .update(biddings) + .set({ + status: 'closed', + updatedBy: payload.currentUserId.toString(), + 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; + 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 { + // 기존 awardBidding 함수 로직을 재구성하여 실행 + const { awardBidding } = await import('@/lib/bidding/detail/service'); + + const result = await awardBidding(payload.biddingId, payload.selectionReason, payload.currentUserId.toString()); + + if (!result.success) { + debugError('[BiddingAwardHandler] 낙찰 처리 실패', result.error); + throw new Error(result.error || '낙찰 처리에 실패했습니다.'); + } + + debugSuccess('[BiddingAwardHandler] 낙찰 완료', { + biddingId: payload.biddingId, + selectionReason: payload.selectionReason, + }); + + return { + success: true, + biddingId: payload.biddingId, + message: `입찰이 낙찰 처리되었습니다.`, + }; + } catch (error) { + debugError('[BiddingAwardHandler] 낙찰 중 에러', error); + throw error; + } +} + +/** + * 낙찰 데이터를 결재 템플릿 변수로 매핑 + * + * @param payload - 낙찰 데이터 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapBiddingAwardToTemplateVariables(payload: { + biddingId: number; + selectionReason: string; + requestedAt: Date; +}): Promise<Record<string, string>> { + 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, + targetPrice: biddings.targetPrice, + winnerCount: biddings.winnerCount, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingAwardMapper] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 2. 낙찰된 업체 정보 조회 + const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); + const 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.winnerCount || 1).toString(); + const contractType = bidding.biddingType || ''; + const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; + const biddingOverview = bidding.itemName || ''; + + // 업체 선정 사유 + const selectionReasonMapped = selectionReason; + + // 낙찰된 업체 정보 매핑 + const vendorVariables: Record<string, string> = {}; + 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<string, string> = {}; + 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, + }; +} |
