From c4f5472b961afb237dc819f9dd3f42a7b8f71075 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 18 Nov 2025 10:30:31 +0000 Subject: (최겸) 구매 입찰 수정, 입찰초대 결재 등록, 재입찰, 차수증가, 폐찰, 유찰취소 로직 수정, readonly 추가 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/approval-actions.ts | 243 ++++++++++++++++++ .../detail/table/bidding-detail-vendor-columns.tsx | 22 +- .../bidding-detail-vendor-toolbar-actions.tsx | 59 ++++- lib/bidding/failure/biddings-failure-table.tsx | 63 ++++- lib/bidding/handlers.ts | 283 +++++++++++++++++++++ lib/bidding/list/biddings-table-columns.tsx | 11 +- lib/bidding/service.ts | 74 +++--- 7 files changed, 702 insertions(+), 53 deletions(-) create mode 100644 lib/bidding/approval-actions.ts create mode 100644 lib/bidding/handlers.ts (limited to 'lib/bidding') diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts new file mode 100644 index 00000000..3a82b08f --- /dev/null +++ b/lib/bidding/approval-actions.ts @@ -0,0 +1,243 @@ +/** + * 입찰초대 관련 결재 서버 액션 + * + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 포함 (서버 액션) + * - UI에서 호출하는 진입점 함수들 + * - withApproval()을 사용하여 결재 프로세스 시작 + * - 템플릿 변수 준비 및 입력 검증 + * - 핸들러(Internal)에는 최소 데이터만 전달 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapBiddingInvitationToTemplateVariables } from './handlers'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; + +/** + * 입찰초대 결재를 거쳐 입찰등록을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingInvitationWithApproval({ + * biddingId: 123, + * vendors: [...], + * message: "입찰 초대 메시지", + * 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 prepareBiddingApprovalData(data: { + 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; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작'); + 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, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + hasSpecificationMeeting: biddings.hasSpecificationMeeting, + isUrgent: biddings.isUrgent, + remarks: biddings.remarks, + targetPrice: biddings.targetPrice, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingInvitationApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 입찰 대상 자재 정보 조회 + const biddingItemsInfo = await db + .select({ + id: prItemsForBidding.id, + projectName: prItemsForBidding.projectInfo, + materialGroup: prItemsForBidding.materialGroupNumber, + materialGroupName: prItemsForBidding.materialGroupInfo, + materialCode: prItemsForBidding.materialNumber, + materialCodeName: prItemsForBidding.materialInfo, + quantity: prItemsForBidding.quantity, + purchasingUnit: prItemsForBidding.purchaseUnit, + targetUnitPrice: prItemsForBidding.targetUnitPrice, + quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, + budget: prItemsForBidding.budgetAmount, + targetAmount: prItemsForBidding.targetAmount, + currency: prItemsForBidding.targetCurrency, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, data.biddingId)); + + debugLog('[BiddingInvitationApproval] 입찰 정보 조회 완료', { + biddingId: bidding.id, + title: bidding.title, + itemCount: biddingItemsInfo.length, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingInvitationToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingInvitationToTemplateVariables({ + bidding, + biddingItems: biddingItemsInfo, + vendors: data.vendors, + message: data.message, + requestedAt, + }); + debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding, + biddingItems: biddingItemsInfo, + variables, + }; +} + +export async function requestBiddingInvitationWithApproval(data: { + 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; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', { + biddingId: data.biddingId, + vendorCount: data.vendors.length, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingInvitationApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (data.vendors.length === 0) { + debugError('[BiddingInvitationApproval] 선정된 업체 없음'); + throw new Error('입찰 초대할 업체를 선택해주세요'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingInvitationApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'approval_pending', // 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingInvitationApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'approval_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding, biddingItems: biddingItemsInfo, variables } = await prepareBiddingApprovalData({ + biddingId: data.biddingId, + vendors: data.vendors, + message: data.message, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingInvitationApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_invitation', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + vendors: data.vendors, + message: data.message, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `입찰초대 - ${bidding.title}`, + description: `${bidding.title} 입찰 초대 결재`, + templateName: '입찰초대 결재', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingInvitationApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingInvitationApproval] 입찰초대 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 6f35405d..80e50119 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -76,17 +76,21 @@ export function getBiddingDetailVendorColumns({ cell: ({ row }) => { const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0 return ( -
+
{hasAmount ? ( - + <> + + ) : ( - - {row.original.currency} + - {row.original.currency} )}
) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index c1677ae7..f2c23de9 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" @@ -37,6 +38,7 @@ export function BiddingDetailVendorToolbarActions({ const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false) const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) const [selectedVendors, setSelectedVendors] = React.useState([]) + const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -176,6 +178,32 @@ export function BiddingDetailVendorToolbarActions({ }) } + const handleRoundIncreaseWithNavigation = () => { + startTransition(async () => { + const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') + + if (result.success) { + toast({ + title: "성공", + description: result.message, + }) + // 새로 생성된 입찰의 상세 페이지로 이동 + if (result.biddingId) { + router.push(`/evcp/bid/${result.biddingId}`) + } else { + router.push(`/evcp/bid`) + } + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "차수증가 중 오류가 발생했습니다.", + variant: 'destructive', + }) + } + }) + } + return ( <>
@@ -185,7 +213,7 @@ export function BiddingDetailVendorToolbarActions({ + + + + + ) } diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx index c80021ea..43020322 100644 --- a/lib/bidding/failure/biddings-failure-table.tsx +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -20,6 +20,7 @@ import { } from "@/db/schema" import { BiddingsClosureDialog } from "./biddings-closure-dialog" import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { FileX, RefreshCw, Undo2 } from "lucide-react" import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions" import { increaseRoundOrRebid } from "@/lib/bidding/service" @@ -85,6 +86,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { const [isCompact, setIsCompact] = React.useState(false) const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false) const [selectedBidding, setSelectedBidding] = React.useState(null) + const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false) + const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState(null) const { toast } = useToast() const [rowAction, setRowAction] = React.useState | null>(null) @@ -103,9 +106,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(rowAction.row.original) switch (rowAction.type) { + case "view": + // 상세 페이지로 이동 + router.push(`/evcp/bid/${rowAction.row.original.id}/info`) + break case "rebid": - // 재입찰 - handleRebid(rowAction.row.original) + // 재입찰 팝업 열기 + setSelectedBiddingForRebid(rowAction.row.original) + setIsRebidDialogOpen(true) break case "closure": // 폐찰 @@ -199,7 +207,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(null) }, []) - const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => { + const handleRebidWithNavigation = React.useCallback(async (bidding: BiddingFailureItem) => { if (!session?.user?.id) { toast({ title: "오류", @@ -215,10 +223,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { if (result.success) { toast({ title: "성공", - description: result.message, + description: (result as any).message || "재입찰이 완료되었습니다.", }) - // 페이지 새로고침 - router.refresh() + // 새로 생성된 입찰의 상세 페이지로 이동 + if ((result as any).biddingId) { + router.push(`/evcp/bid/${(result as any).biddingId}`) + } else { + router.refresh() + } } else { toast({ title: "오류", @@ -352,7 +364,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { return } const bidding = selectedRows[0].original - handleRebid(bidding) + setSelectedBiddingForRebid(bidding) + setIsRebidDialogOpen(true) }} disabled={table.getFilteredSelectedRowModel().rows.length !== 1 || (table.getFilteredSelectedRowModel().rows.length === 1 && @@ -407,7 +420,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { {/* 폐찰 다이얼로그 */} {selectedBidding && session?.user?.id && ( - )} + + {/* 재입찰 확인 다이얼로그 */} + + + + 재입찰 확인 + + 입찰을 재입찰 처리하시겠습니까? 재입찰 후 새로운 입찰 화면으로 이동합니다. + + + + + + + + ) } diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts new file mode 100644 index 00000000..fc2951d4 --- /dev/null +++ b/lib/bidding/handlers.ts @@ -0,0 +1,283 @@ +/** + * 입찰초대 관련 결재 액션 핸들러 + * + * ✅ 베스트 프랙티스: + * - '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; + 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; + requestedAt: Date; +}): Promise> { + const { bidding, biddingItems, vendors, message, requestedAt } = payload; + + // 제목 + const title = bidding.title || '입찰'; + + // 입찰명 + const biddingTitle = bidding.title || ''; + + // 입찰번호 + const biddingNumber = bidding.biddingNumber || ''; + + // 낙찰업체수 + const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + + // 계약구분 + const contractType = bidding.biddingType || ''; + + // P/R번호 - bidding 테이블에 없으므로 빈 값 + const prNumber = ''; + + // 예산 + const budget = bidding.targetPrice ? bidding.targetPrice.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 biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || ''; + + // 협력사 정보들 + 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 ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; + const specMeetingEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const specMeetingStartDup = specMeetingStart; + const specMeetingEndDup = specMeetingEnd; + + // 입찰서제출기간 정보 + const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 + const submissionPeriodStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; + const submissionPeriodEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + + // 대상 자재 수 + 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, + 낙찰업체수: winnerCount, + 계약구분: contractType, + 'P/R번호': prNumber, + 예산: budget, + 내정가: targetPrice, + 입찰요청_시스템: requestSystem, + 입찰담당자: biddingManager, + 내정가_산정_기준: targetPriceBasis, + 입찰개요: biddingOverview, + 입찰공고문: biddingNotice, + ...vendorVariables, + 사양설명회_실행여부: hasSpecMeeting, + 사양설명회_시작예정일시: specMeetingStart, + 사양설명회_종료예정일시: specMeetingEnd, + 입찰서제출기간_실행여부: submissionPeriodExecution, + 입찰서제출기간_시작예정일시: submissionPeriodStart, + 입찰서제출기간_종료예정일시: submissionPeriodEnd, + 대상_자재_수: targetMaterialCount, + ...materialVariables, + }; +} diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 92b2fe42..48c32302 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -96,11 +96,11 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef cell: ({ row }) => (
{row.original.biddingNumber} - {row.original.revision > 0 && ( + {/* {row.original.revision > 0 && ( Rev.{row.original.revision} - )} + )} */}
), size: 120, @@ -137,16 +137,15 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => , cell: ({ row }) => (
- {/* */} - {row.original.title} +
), size: 200, diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index cbeeb24a..68ae016e 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -3177,22 +3177,28 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - // 2. 입찰번호 파싱 및 차수 증가 - const currentBiddingNumber = existingBidding.biddingNumber - - // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02) - const match = currentBiddingNumber.match(/-(\d+)$/) - let currentRound = match ? parseInt(match[1]) : 1 - + // 2. 입찰번호 생성 (타입에 따라 다르게 처리) let newBiddingNumber: string - if (currentRound >= 3) { - // -03 이상이면 새로운 번호 생성 + if (type === 'rebidding') { + // 재입찰: 완전히 새로운 입찰번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) } else { - // -02까지는 차수만 증가 - const baseNumber = currentBiddingNumber.split('-')[0] - newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + // 차수증가: 기존 입찰번호에서 차수 증가 + const currentBiddingNumber = existingBidding.biddingNumber + + // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02) + const match = currentBiddingNumber.match(/-(\d+)$/) + let currentRound = match ? parseInt(match[1]) : 1 + + if (currentRound >= 3) { + // -03 이상이면 새로운 번호 생성 + newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + } else { + // -02까지는 차수만 증가 + const baseNumber = currentBiddingNumber.split('-')[0] + newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + } } // 3. 새로운 입찰 생성 (기존 정보 복제) @@ -3200,7 +3206,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u .insert(biddings) .values({ biddingNumber: newBiddingNumber, - originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null + originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정 revision: 0, biddingSourceType: existingBidding.biddingSourceType, @@ -3419,26 +3425,36 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u }) } } - // 8. 입찰공고문 정보 복제 (있는 경우) - if (existingBidding.hasBiddingNotice) { - const [existingNotice] = await tx - .select() - .from(biddingNoticeTemplate) - .where(eq(biddingNoticeTemplate.biddingId, biddingId)) - .limit(1) - if (existingNotice) { - await tx - .insert(biddingNoticeTemplate) - .values({ - biddingId: newBidding.id, - title: existingNotice.title, - content: existingNotice.content, - }) - } + // 9. 기존 입찰 상태 변경 (타입에 따라 다르게 설정) + await tx + .update(biddings) + .set({ + status: type === 'round_increase' ? 'round_increase' : 'rebidding', + updatedBy: userName, + updatedAt: new Date(), + }) + .where(eq(biddings.id, biddingId)) + + // 10. 입찰공고문 정보 복제 (있는 경우) + const [existingNotice] = await tx + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.biddingId, biddingId)) + .limit(1) + + if (existingNotice) { + await tx + .insert(biddingNoticeTemplate) + .values({ + biddingId: newBidding.id, + title: existingNotice.title, + content: existingNotice.content, + }) } revalidatePath('/bidding') + revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 revalidatePath(`/bidding/${newBidding.id}`) return { -- cgit v1.2.3