summaryrefslogtreecommitdiff
path: root/lib/bidding/handlers.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/handlers.ts')
-rw-r--r--lib/bidding/handlers.ts429
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,
+ };
+}