From a8674e6b91fb4d356c311fad0251878de154da53 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 24 Nov 2025 11:16:32 +0000 Subject: (최겸) 구매 입찰 수정(폐찰, 낙찰 결재 기능 추가 등) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval/handlers-registry.ts | 10 + ...\230 \354\232\224\354\262\255\354\204\234.html" | 788 +++++++++++++++++++++ ...\230 \354\232\224\354\262\255\354\204\234.html" | 581 +++++++++++++++ lib/bidding/approval-actions.ts | 325 ++++++++- lib/bidding/detail/bidding-actions.ts | 160 ++--- lib/bidding/detail/service.ts | 58 +- lib/bidding/detail/table/bidding-award-dialog.tsx | 190 ++++- .../detail/table/bidding-detail-vendor-table.tsx | 77 ++ lib/bidding/failure/biddings-closure-dialog.tsx | 77 +- lib/bidding/failure/biddings-failure-table.tsx | 81 ++- lib/bidding/handlers.ts | 429 +++++++++++ lib/bidding/list/bidding-pr-documents-dialog.tsx | 2 +- lib/bidding/list/create-bidding-dialog.tsx | 64 +- lib/bidding/service.ts | 69 +- lib/bidding/validation.ts | 2 +- .../vendor/components/pr-items-pricing-table.tsx | 8 +- lib/bidding/vendor/partners-bidding-detail.tsx | 27 +- .../vendor/partners-bidding-list-columns.tsx | 13 + lib/rfq-last/quotation-compare-view.tsx | 2 - .../editor/commercial-terms-form.tsx | 23 +- lib/rfq-last/vendor/price-adjustment-dialog.tsx | 23 +- .../table/detail-table/rfq-detail-table.tsx | 10 +- .../tech-sales-rfq-attachments-sheet-copy-1118.tsx | 710 ------------------- .../bid-history-table-columns.tsx | 2 - .../bid-history-table/bid-history-table.tsx | 2 - 25 files changed, 2835 insertions(+), 898 deletions(-) create mode 100644 "lib/approval/templates/\354\236\205\354\260\260 \352\262\260\352\263\274 \354\227\205\354\262\264 \354\204\240\354\240\225 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" create mode 100644 "lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" delete mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx (limited to 'lib') diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index 5c173565..beb6b971 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -68,6 +68,16 @@ export async function initializeApprovalHandlers() { // 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal) registerActionHandler('bidding_invitation', requestBiddingInvitationInternal); + // 9. 폐찰 핸들러 + const { requestBiddingClosureInternal } = await import('@/lib/bidding/handlers'); + // 폐찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingClosureInternal) + registerActionHandler('bidding_closure', requestBiddingClosureInternal); + + // 10. 낙찰 핸들러 + const { requestBiddingAwardInternal } = await import('@/lib/bidding/handlers'); + // 낙찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingAwardInternal) + registerActionHandler('bidding_award', requestBiddingAwardInternal); + // ... 추가 핸들러 등록 console.log('[Approval Handlers] All handlers registered successfully'); diff --git "a/lib/approval/templates/\354\236\205\354\260\260 \352\262\260\352\263\274 \354\227\205\354\262\264 \354\204\240\354\240\225 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" "b/lib/approval/templates/\354\236\205\354\260\260 \352\262\260\352\263\274 \354\227\205\354\262\264 \354\204\240\354\240\225 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" new file mode 100644 index 00000000..50e1ff54 --- /dev/null +++ "b/lib/approval/templates/\354\236\205\354\260\260 \352\262\260\352\263\274 \354\227\205\354\262\264 \354\204\240\354\240\225 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" @@ -0,0 +1,788 @@ +
+ + + + + + + + + + + +
+ 입찰 결과 업체 선정 품의 요청서 ({{제목}}) +
+ *결재 완료 후 낙찰이 반영되며, 협력사로 통보됩니다. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 기본 정보 +
+ 입찰명 + + {{입찰명}} + + 입찰번호 + + {{입찰번호}} +
+ 낙찰업체수 + + {{낙찰업체수}} + + 계약구분 + + {{계약구분}} +
+ P/R번호 + + {{P/R번호}} + + 예산 + + {{예산}} +
+ 내정액 + + {{내정액}} + + 입찰담당자 + + {{입찰담당자}} +
+ 입찰 개요 + + {{입찰개요}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 업체 선정 결과 +
+ 선정 사유 + + {{업체선정사유}} +
+ 순번 + + 협력사 코드 + + 협력사명 + + 기업규모 + + 연동제 희망 + + 연동제 적용 + + 낙찰 유무 + + 확정 금액 + + 내정액 + + 입찰액 + + 입찰액/내정액(%) +
1{{협력사_코드_1}}{{협력사명_1}}{{기업규모_1}}{{연동제희망여부_1}}{{연동제적용여부_1}}{{낙찰유무_1}}{{확정금액_1}}{{내정액_1}}{{입찰액_1}}{{비율_1}}
2{{협력사_코드_2}}{{협력사명_2}}{{기업규모_2}}{{연동제희망여부_2}}{{연동제적용여부_2}}{{낙찰유무_2}}{{확정금액_2}}{{내정액_2}}{{입찰액_2}}{{비율_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 품목별 입찰 정보 (총 {{대상_자재_수}} 건 - 결재본문 내 표시 품목은 10건 이하로 제한됩니다) +
+ 순번 + + 자재번호 + + 자재내역(품목명) + + 구매단위 + + 수량 + + 수량단위 + + 총중량 + + 중량단위 + + 통화 + + 내정액 + + 낙찰 협력사명 + + 입찰액 +
1{{자재번호_1}}{{자재내역_1}}{{구매단위_1}}{{수량_1}}{{수량단위_1}}{{총중량_1}}{{중량단위_1}}{{통화_1}}{{내정액_1}}{{협력사명_1}}{{입찰액_1}}
2{{자재번호_2}}{{자재내역_2}}{{구매단위_2}}{{수량_2}}{{수량단위_2}}{{총중량_2}}{{중량단위_2}}{{통화_2}}{{내정액_2}}{{협력사명_2}}{{입찰액_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 연동제 NOTE +
+ 업체명 + + 연동 합의서 + + 미연동 합의서 +
{{업체명_연동제_1}}[첨부파일 링크 또는 Y/N][첨부파일 링크 또는 Y/N]
{{업체명_연동제_2}}[첨부파일 링크 또는 Y/N][첨부파일 링크 또는 Y/N]
+ +
diff --git "a/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" "b/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" new file mode 100644 index 00000000..dafda83c --- /dev/null +++ "b/lib/approval/templates/\355\217\220\354\260\260 \355\222\210\354\235\230 \354\232\224\354\262\255\354\204\234.html" @@ -0,0 +1,581 @@ +
+ + + + + + + + + + + +
+ 폐찰 품의 요청서 ({{제목}}) +
+ *결재 완료 후 폐찰 처리됩니다. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 기본 정보 +
+ 입찰명 + + {{입찰명}} + + 입찰번호 + + {{입찰번호}} +
+ 낙찰업체수 + + {{낙찰업체수}} + + 계약구분 + + {{계약구분}} +
+ 내정가 + + {{내정가}} + + 입찰담당자 + + {{입찰담당자}} +
+ 입찰 개요 + + {{입찰개요}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 입찰 현황 및 폐찰 결과 +
+ 폐찰 사유 + + {{폐찰_사유}} +
+ 순번 + + 협력사 코드 + + 협력사명 + + 응찰 유무 + + 내정가 + + 입찰가 + + 입찰가/내정가(%) +
1{{협력사_코드_1}}{{협력사명_1}}{{응찰유무_1}}{{내정가_1}}{{입찰가_1}}{{비율_1}}
2{{협력사_코드_2}}{{협력사명_2}}{{응찰유무_2}}{{내정가_2}}{{입찰가_2}}{{비율_2}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 품목별 입찰 정보 +
+ 순번 + + 품목 코드 + + 품목명 + + 수량 + + 단위 + + 통화 + + 내정가 + + 협력사 코드 + + 협력사명 + + 입찰가 +
1{{품목코드_1}}{{품목명_1}}{{수량_1}}{{단위_1}}{{통화_1}}{{내정가_1}}{{협력사코드_1}}{{협력사명_1}}{{입찰가_1}}
2{{품목코드_2}}{{품목명_2}}{{수량_2}}{{단위_2}}{{통화_2}}{{내정가_2}}{{협력사코드_2}}{{협력사명_2}}{{입찰가_2}}
+ +
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3a82b08f..6f02e80c 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -12,7 +12,7 @@ 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; -import { mapBiddingInvitationToTemplateVariables } from './handlers'; +import { mapBiddingInvitationToTemplateVariables, mapBiddingClosureToTemplateVariables, mapBiddingAwardToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** @@ -99,7 +99,7 @@ export async function prepareBiddingApprovalData(data: { materialCode: prItemsForBidding.materialNumber, materialCodeName: prItemsForBidding.materialInfo, quantity: prItemsForBidding.quantity, - purchasingUnit: prItemsForBidding.purchaseUnit, + purchasingUnit: prItemsForBidding.priceUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, quantityUnit: prItemsForBidding.quantityUnit, totalWeight: prItemsForBidding.totalWeight, @@ -241,3 +241,324 @@ export async function requestBiddingInvitationWithApproval(data: { return result; } + +/** + * 폐찰 결재를 거쳐 입찰 폐찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingClosureWithApproval({ + * biddingId: 123, + * description: "폐찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 폐찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingClosureApprovalData(data: { + biddingId: number; + description: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingClosureToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingClosureToTemplateVariables({ + biddingId: data.biddingId, + description: data.description, + requestedAt, + }); + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingClosureWithApproval(data: { + biddingId: number; + description: string; + files?: File[]; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingClosureApproval] 폐찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + description: data.description, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingClosureApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.description.trim()) { + debugError('[BiddingClosureApproval] 폐찰 사유 없음'); + throw new Error('폐찰 사유를 입력해주세요'); + } + // 유찰상태인지 확인 + const { bidding } = await db + .select() + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (bidding.status !== 'bidding_disposal') { + debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.'); + throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); + 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: 'closure_pending', // 폐찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingClosureApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'closure_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding: approvalBidding, variables } = await prepareBiddingClosureApprovalData({ + biddingId: data.biddingId, + description: data.description, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingClosureApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_closure', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + description: data.description, + files: data.files, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `폐찰 - ${approvalBidding.title}`, + description: `${approvalBidding.title} 입찰 폐찰 결재`, + templateName: '폐찰 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingClosureApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingClosureApproval] 폐찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} + +/** + * 낙찰 결재를 거쳐 입찰 낙찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingAwardWithApproval({ + * biddingId: 123, + * selectionReason: "낙찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingAwardApprovalData(data: { + biddingId: number; + selectionReason: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingAwardApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingAwardApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingAwardToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + requestedAt, + }); + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingAwardWithApproval(data: { + biddingId: number; + selectionReason: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingAwardApproval] 낙찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingAwardApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.selectionReason.trim()) { + debugError('[BiddingAwardApproval] 낙찰 사유 없음'); + throw new Error('낙찰 사유를 입력해주세요'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingAwardApproval] 입찰 상태 변경 시작'); + 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: 'award_pending', // 낙찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingAwardApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'award_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding, variables } = await prepareBiddingAwardApprovalData({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingAwardApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_award', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `낙찰 - ${bidding.title}`, + description: `${bidding.title} 입찰 낙찰 결재`, + templateName: '입찰 결과 업체 선정 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingAwardApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingAwardApproval] 낙찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts index 70bba1c3..fb659039 100644 --- a/lib/bidding/detail/bidding-actions.ts +++ b/lib/bidding/detail/bidding-actions.ts @@ -143,85 +143,85 @@ export async function checkAllVendorsFinalSubmitted(biddingId: number) { } } -// 개찰 서버 액션 (조기개찰/개찰 구분) -export async function performBidOpening( - biddingId: number, - userId: string, - isEarly: boolean = false // 조기개찰 여부 -) { - try { - const userName = await getUserNameById(userId) +// // 개찰 서버 액션 (조기개찰/개찰 구분) +// export async function performBidOpening( +// biddingId: number, +// userId: string, +// isEarly: boolean = false // 조기개찰 여부 +// ) { +// try { +// const userName = await getUserNameById(userId) - return await db.transaction(async (tx) => { - // 1. 입찰 정보 조회 - const [bidding] = await tx - .select({ - id: biddings.id, - status: biddings.status, - submissionEndDate: biddings.submissionEndDate, - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!bidding) { - return { - success: false, - error: '입찰 정보를 찾을 수 없습니다.' - } - } - - // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만) - if (bidding.status !== 'evaluation_of_bidding') { - return { - success: false, - error: '입찰평가중 상태에서만 개찰할 수 있습니다.' - } - } - - // 3. 모든 벤더가 최종제출했는지 확인 - const checkResult = await checkAllVendorsFinalSubmitted(biddingId) - if (!checkResult.allSubmitted) { - return { - success: false, - error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})` - } - } - - // 4. 조기개찰 여부 결정 - const now = new Date() - const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null - const isBeforeDeadline = submissionEndDate && now < submissionEndDate - - // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰 - const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening' - - // 5. 입찰 상태 변경 - await tx - .update(biddings) - .set({ - status: newStatus, - updatedAt: new Date() - }) - .where(eq(biddings.id, biddingId)) - - // 캐시 무효화 - revalidateTag(`bidding-${biddingId}`) - revalidateTag('bidding-detail') - revalidatePath(`/evcp/bid/${biddingId}`) - - return { - success: true, - message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`, - status: newStatus - } - }) - } catch (error) { - console.error('Failed to perform bid opening:', error) - return { - success: false, - error: error instanceof Error ? error.message : '개찰에 실패했습니다.' - } - } -} +// return await db.transaction(async (tx) => { +// // 1. 입찰 정보 조회 +// const [bidding] = await tx +// .select({ +// id: biddings.id, +// status: biddings.status, +// submissionEndDate: biddings.submissionEndDate, +// }) +// .from(biddings) +// .where(eq(biddings.id, biddingId)) +// .limit(1) + +// if (!bidding) { +// return { +// success: false, +// error: '입찰 정보를 찾을 수 없습니다.' +// } +// } + +// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만) +// if (bidding.status !== 'evaluation_of_bidding') { +// return { +// success: false, +// error: '입찰평가중 상태에서만 개찰할 수 있습니다.' +// } +// } + +// // 3. 모든 벤더가 최종제출했는지 확인 +// const checkResult = await checkAllVendorsFinalSubmitted(biddingId) +// if (!checkResult.allSubmitted) { +// return { +// success: false, +// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})` +// } +// } + +// // 4. 조기개찰 여부 결정 +// const now = new Date() +// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null +// const isBeforeDeadline = submissionEndDate && now < submissionEndDate + +// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰 +// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening' + +// // 5. 입찰 상태 변경 +// await tx +// .update(biddings) +// .set({ +// status: newStatus, +// updatedAt: new Date() +// }) +// .where(eq(biddings.id, biddingId)) + +// // 캐시 무효화 +// revalidateTag(`bidding-${biddingId}`) +// revalidateTag('bidding-detail') +// revalidatePath(`/evcp/bid/${biddingId}`) + +// return { +// success: true, +// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`, +// status: newStatus +// } +// }) +// } catch (error) { +// console.error('Failed to perform bid opening:', error) +// return { +// success: false, +// error: error instanceof Error ? error.message : '개찰에 실패했습니다.' +// } +// } +// } diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index d0f8070f..297c6f98 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1251,9 +1251,55 @@ export async function getAwardedCompanies(biddingId: number) { } } +// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 +async function updateBiddingAmounts(biddingId: number) { + try { + // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 + const amounts = await db + .select({ + totalTargetAmount: sql`COALESCE(SUM(${prItemsForBidding.targetAmount}), 0)`, + totalBudgetAmount: sql`COALESCE(SUM(${prItemsForBidding.budgetAmount}), 0)`, + totalActualAmount: sql`COALESCE(SUM(${prItemsForBidding.actualAmount}), 0)` + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + const { totalTargetAmount, totalBudgetAmount, totalActualAmount } = amounts[0] + + // bidding 테이블 업데이트 + await db + .update(biddings) + .set({ + targetPrice: totalTargetAmount, + budget: totalBudgetAmount, + finalBidPrice: totalActualAmount, + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + console.log(`Bidding ${biddingId} amounts updated: target=${totalTargetAmount}, budget=${totalBudgetAmount}, actual=${totalActualAmount}`) + } catch (error) { + console.error('Failed to update bidding amounts:', error) + throw error + } +} + // PR 품목 정보 업데이트 export async function updatePrItem(prItemId: number, input: Partial, userId: string) { try { + // 업데이트 전 biddingId 확인 + const prItem = await db + .select({ biddingId: prItemsForBidding.biddingId }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.id, prItemId)) + .limit(1) + + if (!prItem[0]?.biddingId) { + throw new Error('PR item not found or biddingId is missing') + } + + const biddingId = prItem[0].biddingId + await db .update(prItemsForBidding) .set({ @@ -1262,12 +1308,14 @@ export async function updatePrItem(prItemId: number, input: Partial void onSuccess: () => void + onApprovalPreview?: (data: { + templateName: string + variables: Record + title: string + selectionReason: string + }) => void } interface AwardedCompany { @@ -47,7 +54,8 @@ export function BiddingAwardDialog({ biddingId, open, onOpenChange, - onSuccess + onSuccess, + onApprovalPreview }: BiddingAwardDialogProps) { const { toast } = useToast() const { data: session } = useSession() @@ -106,26 +114,36 @@ const userId = session?.user?.id || '2'; return } - startTransition(async () => { - const result = await awardBidding(biddingId, selectionReason, userId) + // 결재 템플릿 변수 준비 + const { mapBiddingAwardToTemplateVariables } = await import('@/lib/bidding/handlers') - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - // 폼 초기화 - setSelectionReason('') - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', + try { + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId, + selectionReason, + requestedAt: new Date() + }) + + // 상위 컴포넌트로 결재 미리보기 데이터 전달 + if (onApprovalPreview) { + onApprovalPreview({ + templateName: '입찰 결과 업체 선정 품의 요청서', + variables, + title: `낙찰 - ${bidding?.title}`, + selectionReason }) } - }) + + onOpenChange(false) + setSelectionReason('') + } catch (error) { + console.error('낙찰 템플릿 변수 준비 실패:', error) + toast({ + title: '오류', + description: '결재 문서 준비 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } } @@ -251,11 +269,143 @@ const userId = session?.user?.id || '2'; type="submit" disabled={isPending || awardedCompanies.length === 0} > - {isPending ? '처리 중...' : '낙찰 완료'} + {isPending ? '상신 중...' : '결재 상신'} ) + + return ( + <> + + + + + + 낙찰 처리 + + + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + + + +
+
+ {/* 낙찰 업체 정보 */} + + + + + 낙찰 업체 정보 + + + + {isLoading ? ( +
+
+

낙찰 업체 정보를 불러오는 중...

+
+ ) : awardedCompanies.length > 0 ? ( +
+ + + + 업체명 + 견적금액 + 발주비율 + 발주금액 + + + + {awardedCompanies.map((company) => ( + + +
+ 낙찰 + {company.companyName} +
+
+ + {company.finalQuoteAmount.toLocaleString()}원 + + + {company.awardRatio}% + + + {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원 + +
+ ))} +
+
+ + {/* 최종입찰가 요약 */} +
+
+ + 최종입찰가 +
+ + {finalBidPrice.toLocaleString()}원 + +
+
+ ) : ( +
+ +

낙찰된 업체가 없습니다

+

+ 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요. +

+
+ )} +
+
+ + {/* 낙찰 사유 */} +
+ +