summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/approval-actions.ts243
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx22
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx59
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx63
-rw-r--r--lib/bidding/handlers.ts283
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx11
-rw-r--r--lib/bidding/service.ts74
7 files changed, 702 insertions, 53 deletions
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 (
- <div className="text-right font-mono">
+ <div className="text-right font-mono font-bold">
{hasAmount ? (
- <button
- onClick={() => onViewQuotationHistory?.(row.original)}
- className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
- >
- {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency}
- </button>
+ <>
+ <button
+ onClick={() => onViewQuotationHistory?.(row.original)}
+ className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
+ title="품목별 견적 상세 보기"
+ >
+ <span className="border-b-2 border-primary">
+ {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency}
+ </span>
+ </button>
+ </>
) : (
- <span className="text-muted-foreground">- {row.original.currency}</span>
+ <span className="text-muted-foreground border-b-2 border-dashed font-bold">- {row.original.currency}</span>
)}
</div>
)
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<any[]>([])
+ 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 (
<>
<div className="flex items-center gap-2">
@@ -185,7 +213,7 @@ export function BiddingDetailVendorToolbarActions({
<Button
variant="outline"
size="sm"
- onClick={handleRoundIncrease}
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
disabled={isPending}
>
<RotateCw className="mr-2 h-4 w-4" />
@@ -250,6 +278,35 @@ export function BiddingDetailVendorToolbarActions({
onSuccess={onSuccess}
/>
+ {/* 차수증가 확인 다이얼로그 */}
+ <Dialog open={isRoundIncreaseDialogOpen} onOpenChange={setIsRoundIncreaseDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>차수증가 확인</DialogTitle>
+ <DialogDescription>
+ 입찰을 차수증가 처리하시겠습니까? 차수증가 후 새로운 입찰 화면으로 이동합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setIsRoundIncreaseDialogOpen(false)}
+ >
+ 아니오
+ </Button>
+ <Button
+ onClick={async () => {
+ setIsRoundIncreaseDialogOpen(false)
+ await handleRoundIncreaseWithNavigation()
+ }}
+ disabled={isPending}
+ >
+ 예
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
</>
)
}
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<boolean>(false)
const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false)
+ const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState<BiddingFailureItem | null>(null)
const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | 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 && (
- <BidClosureDialog
+ <BiddingsClosureDialog
open={biddingClosureDialogOpen}
onOpenChange={handleBiddingClosureDialogClose}
bidding={selectedBidding}
@@ -418,6 +431,40 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
}}
/>
)}
+
+ {/* 재입찰 확인 다이얼로그 */}
+ <Dialog open={isRebidDialogOpen} onOpenChange={setIsRebidDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>재입찰 확인</DialogTitle>
+ <DialogDescription>
+ 입찰을 재입찰 처리하시겠습니까? 재입찰 후 새로운 입찰 화면으로 이동합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 아니오
+ </Button>
+ <Button
+ onClick={async () => {
+ if (selectedBiddingForRebid) {
+ await handleRebidWithNavigation(selectedBiddingForRebid)
+ }
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 예
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</>
)
}
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<string, string>)
+ */
+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<Record<string, string>> {
+ 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<string, string> = {};
+ 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<string, string> = {};
+ 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 }) => (
<div className="font-mono text-sm">
{row.original.biddingNumber}
- {row.original.revision > 0 && (
+ {/* {row.original.revision > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
Rev.{row.original.revision}
</span>
- )}
+ )} */}
</div>
),
size: 120,
@@ -137,16 +137,15 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
cell: ({ row }) => (
<div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
+ <Button
variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
+ className="p-0 h-auto font-bold underline"
onClick={() => setRowAction({ row, type: "view" })}
>
<div className="whitespace-pre-line">
{row.original.title}
</div>
- </Button> */}
- {row.original.title}
+ </Button>
</div>
),
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 {