summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/approval-actions.ts53
-rw-r--r--lib/bidding/detail/service.ts18
-rw-r--r--lib/bidding/detail/table/bidding-award-dialog.tsx136
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx9
-rw-r--r--lib/bidding/failure/biddings-closure-dialog.tsx5
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx3
-rw-r--r--lib/bidding/handlers.ts86
-rw-r--r--lib/bidding/service.ts81
8 files changed, 325 insertions, 66 deletions
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 06f5c206..dd88164d 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -479,11 +479,47 @@ export async function requestBiddingClosureWithApproval(data: {
* ```
*/
/**
+ * 낙찰할 업체 정보 조회 헬퍼 함수
+ */
+export async function getAwardedCompaniesForApproval(biddingId: number) {
+ const { default: db } = await import('@/db/db');
+ const { biddingCompanies, vendors } = await import('@/db/schema');
+ const { eq, and } = await import('drizzle-orm');
+
+ const awardedCompanies = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ awardRatio: biddingCompanies.awardRatio
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ ));
+
+ return awardedCompanies.map(company => ({
+ companyId: company.companyId,
+ companyName: company.companyName || '',
+ finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'),
+ awardRatio: parseFloat(company.awardRatio?.toString() || '0')
+ }));
+}
+
+/**
* 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수
*/
export async function prepareBiddingAwardApprovalData(data: {
biddingId: number;
selectionReason: string;
+ awardedCompanies?: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ }>;
}) {
// 1. 입찰 정보 조회 (템플릿 변수용)
debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작');
@@ -510,6 +546,14 @@ export async function prepareBiddingAwardApprovalData(data: {
title: biddingInfo[0].title,
});
+ // 낙찰할 업체 정보 조회 (파라미터로 제공되지 않은 경우)
+ const awardedCompanies = data.awardedCompanies || await getAwardedCompaniesForApproval(data.biddingId);
+
+ if (awardedCompanies.length === 0) {
+ debugError('[BiddingAwardApproval] 낙찰된 업체가 없습니다.');
+ throw new Error('낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.');
+ }
+
// 2. 템플릿 변수 매핑
debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작');
const requestedAt = new Date();
@@ -518,6 +562,7 @@ export async function prepareBiddingAwardApprovalData(data: {
biddingId: data.biddingId,
selectionReason: data.selectionReason,
requestedAt,
+ awardedCompanies, // 낙찰 업체 정보 전달
});
debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', {
variableKeys: Object.keys(variables),
@@ -532,6 +577,12 @@ export async function prepareBiddingAwardApprovalData(data: {
export async function requestBiddingAwardWithApproval(data: {
biddingId: number;
selectionReason: string;
+ awardedCompanies: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ }>;
currentUser: { id: number; epId: string | null; email?: string };
approvers?: string[]; // Knox EP ID 배열 (결재선)
}) {
@@ -577,6 +628,7 @@ export async function requestBiddingAwardWithApproval(data: {
const { bidding, variables } = await prepareBiddingAwardApprovalData({
biddingId: data.biddingId,
selectionReason: data.selectionReason,
+ awardedCompanies: data.awardedCompanies,
});
// 4. 결재 워크플로우 시작 (Saga 패턴)
@@ -589,6 +641,7 @@ export async function requestBiddingAwardWithApproval(data: {
{
biddingId: data.biddingId,
selectionReason: data.selectionReason,
+ awardedCompanies: data.awardedCompanies, // ✅ 결재 상신 시점의 낙찰 대상 정보
currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요
},
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index c9ad43ff..8f9bf018 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -912,13 +912,11 @@ export async function registerBidding(biddingId: number, userId: string) {
}
}
}
- // 캐시 무효화
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidateTag('quotation-vendors')
- revalidateTag('quotation-details')
- revalidateTag('pr-items')
- revalidatePath(`/evcp/bid/${biddingId}`)
+ // 캐시 무효화 (API를 통한 방식)
+ const { revalidateViaCronJob } = await import('@/lib/revalidation-utils');
+ await revalidateViaCronJob({
+ tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'quotation-details', 'pr-items']
+ });
debugSuccess(`registerBidding: Success. Invited ${selectedCompanies.length} companies.`)
return {
@@ -1290,9 +1288,9 @@ async function updateBiddingAmounts(biddingId: number) {
await db
.update(biddings)
.set({
- targetPrice: totalTargetAmount,
- budget: totalBudgetAmount,
- finalBidPrice: totalActualAmount,
+ targetPrice: totalTargetAmount.toString(),
+ budget: totalBudgetAmount.toString(),
+ finalBidPrice: totalActualAmount.toString(),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx
index b168e884..613e2fc8 100644
--- a/lib/bidding/detail/table/bidding-award-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-award-dialog.tsx
@@ -27,14 +27,27 @@ import {
import { Trophy, Building2, Calculator } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { getAwardedCompanies } from '@/lib/bidding/detail/service'
-import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions'
+import { requestBiddingAwardWithApproval, prepareBiddingAwardApprovalData } from '@/lib/bidding/approval-actions'
import { AwardSimpleFileUpload } from './components/award-simple-file-upload'
+import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
interface BiddingAwardDialogProps {
biddingId: number
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
+ onApprovalPreview?: (data: {
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ selectionReason: string
+ awardedCompanies: {
+ companyId: number
+ companyName: string | null
+ finalQuoteAmount: number
+ awardRatio: number
+ }[]
+ }) => void
}
interface AwardedCompany {
@@ -48,7 +61,8 @@ export function BiddingAwardDialog({
biddingId,
open,
onOpenChange,
- onSuccess
+ onSuccess,
+ onApprovalPreview
}: BiddingAwardDialogProps) {
const { toast } = useToast()
const { data: session } = useSession()
@@ -56,7 +70,10 @@ export function BiddingAwardDialog({
const [selectionReason, setSelectionReason] = React.useState('')
const [awardedCompanies, setAwardedCompanies] = React.useState<AwardedCompany[]>([])
const [isLoading, setIsLoading] = React.useState(false)
-const userId = session?.user?.id || '2';
+ const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false)
+ const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({})
+ const [approvalTitle, setApprovalTitle] = React.useState('')
+ const userId = session?.user?.id || '2';
// 낙찰된 업체 정보 로드
React.useEffect(() => {
if (open) {
@@ -86,9 +103,8 @@ const userId = session?.user?.id || '2';
}, 0)
}, [awardedCompanies])
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault()
-
+ // 다음단계 버튼 핸들러 - 결재 준비 및 부모로 데이터 전달
+ const handleNextStep = async () => {
if (!selectionReason.trim()) {
toast({
title: '유효성 오류',
@@ -107,45 +123,83 @@ const userId = session?.user?.id || '2';
return
}
- // 서버 액션을 사용하여 결재 상신
- startTransition(async () => {
- try {
- const result = await requestBiddingAwardWithApproval({
- biddingId,
+ try {
+ // 결재 데이터 준비 (템플릿 변수, 제목 등)
+ const approvalData = await prepareBiddingAwardApprovalData({
+ biddingId,
+ selectionReason: selectionReason.trim(),
+ awardedCompanies,
+ })
+
+ // 결재 준비 완료 - 부모 컴포넌트의 결재 미리보기 콜백 호출
+ if (onApprovalPreview) {
+ onApprovalPreview({
+ templateName: '입찰 결과 업체 선정 품의 요청서',
+ variables: approvalData.variables,
+ title: `낙찰 - ${approvalData.bidding.title}`,
selectionReason: selectionReason.trim(),
- currentUser: {
- id: Number(userId),
- epId: session?.user?.epId || null,
- email: session?.user?.email || undefined,
- },
+ awardedCompanies,
})
+ } else {
+ // 기존 로직 유지 (내부 결재 다이얼로그 사용)
+ setApprovalVariables(approvalData.variables)
+ setApprovalTitle(`낙찰 - ${approvalData.bidding.title}`)
+ setIsApprovalDialogOpen(true)
+ }
+ } catch (error) {
+ console.error('결재 준비 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '결재 준비 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
- if (result.status === 'pending_approval') {
- toast({
- title: '성공',
- description: '낙찰 결재가 상신되었습니다.',
- })
- onOpenChange(false)
- setSelectionReason('')
- onSuccess()
- } else {
- toast({
- title: '오류',
- description: '결재 상신에 실패했습니다.',
- variant: 'destructive',
- })
- }
- } catch (error) {
- console.error('낙찰 결재 상신 실패:', error)
+ // 결재 상신 핸들러 - 결재 완료 시 실제 낙찰 등록 실행
+ const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => {
+ try {
+ if (!session?.user?.id || !session.user.epId) {
toast({
title: '오류',
- description: error instanceof Error ? error.message : '결재 상신 중 오류가 발생했습니다.',
+ description: '필요한 정보가 없습니다.',
variant: 'destructive',
})
+ return
}
- })
- }
+ // 결재 상신
+ const result = await requestBiddingAwardWithApproval({
+ biddingId,
+ selectionReason: selectionReason.trim(),
+ awardedCompanies, // ✅ 결재 상신 시점의 낙찰 대상 정보 전달
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ email: session.user.email || undefined,
+ },
+ approvers,
+ })
+
+ if (result.status === 'pending_approval') {
+ toast({
+ title: '낙찰 결재 상신 완료',
+ description: `결재가 상신되었습니다. (ID: ${result.approvalId})`,
+ })
+ setIsApprovalDialogOpen(false)
+ onOpenChange(false)
+ setSelectionReason('')
+ onSuccess()
+ }
+ } catch (error) {
+ console.error('결재 상신 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -160,7 +214,7 @@ const userId = session?.user?.id || '2';
</DialogDescription>
</DialogHeader>
- <form onSubmit={handleSubmit}>
+ <div>
<div className="space-y-6">
{/* 낙찰 업체 정보 */}
<Card>
@@ -266,14 +320,16 @@ const userId = session?.user?.id || '2';
취소
</Button>
<Button
- type="submit"
+ type="button"
+ onClick={handleNextStep}
disabled={isPending || awardedCompanies.length === 0}
>
- {isPending ? '상신 중...' : '결재 상신'}
+ 다음단계
</Button>
</DialogFooter>
- </form>
+ </div>
</DialogContent>
</Dialog>
+
)
}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index 08fc0293..edb72aca 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -15,6 +15,7 @@ import { Bidding } from '@/db/schema'
import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
+import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions'
import { useToast } from '@/hooks/use-toast'
@@ -106,6 +107,12 @@ export function BiddingDetailVendorTableContent({
variables: Record<string, string>
title: string
selectionReason: string
+ awardedCompanies: {
+ companyId: number
+ companyName: string | null
+ finalQuoteAmount: number
+ awardRatio: number
+ }[]
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
@@ -204,6 +211,7 @@ export function BiddingDetailVendorTableContent({
const result = await requestBiddingAwardWithApproval({
biddingId,
selectionReason: approvalPreviewData.selectionReason,
+ awardedCompanies: approvalPreviewData.awardedCompanies,
currentUser: {
id: Number(session.user.id),
epId: session.user.epId || null,
@@ -273,6 +281,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
onApprovalPreview={(data) => {
setApprovalPreviewData(data)
+ setIsAwardDialogOpen(false)
setIsApprovalPreviewDialogOpen(true)
}}
/>
diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx
index cea1f42a..1655d91b 100644
--- a/lib/bidding/failure/biddings-closure-dialog.tsx
+++ b/lib/bidding/failure/biddings-closure-dialog.tsx
@@ -43,15 +43,12 @@ interface BiddingsClosureDialogProps {
}
try {
- // 결재자 선택 단계로 데이터 전달
+ // 결재자 선택 단계로 데이터 전달 (다이얼로그 닫기는 부모 컴포넌트에서 처리)
await onApprovalPreview({
description: description.trim(),
files: files,
biddingId: bidding.id,
})
-
- // 다이얼로그 닫기
- onOpenChange(false)
} catch (error) {
console.error('결재 미리보기 준비 실패:', error)
toast.error('결재 미리보기 준비 중 오류가 발생했습니다.')
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
index c4e3be06..fc931307 100644
--- a/lib/bidding/failure/biddings-failure-table.tsx
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -284,6 +284,9 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
description: data.description,
files: data.files,
})
+
+ // 폐찰 다이얼로그 닫고 결재 미리보기 열기
+ setBiddingClosureDialogOpen(false)
setIsApprovalPreviewDialogOpen(true)
} catch (error) {
console.error('결재 준비 중 오류 발생:', error)
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index a5cc72ae..760d7900 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -418,7 +418,7 @@ export async function requestBiddingClosureInternal(payload: {
await db
.update(biddings)
.set({
- status: 'closed',
+ status: 'bid_closure',
updatedBy: payload.currentUserId.toString(),
updatedAt: new Date(),
remarks: payload.description, // 폐찰 사유를 remarks에 저장
@@ -490,6 +490,12 @@ export async function requestBiddingClosureInternal(payload: {
export async function requestBiddingAwardInternal(payload: {
biddingId: number;
selectionReason: string;
+ awardedCompanies: Array<{
+ companyId: number;
+ companyName: string | null;
+ finalQuoteAmount: number;
+ awardRatio: number;
+ }>; // ✅ 결재 상신 시점의 낙찰 대상 정보
currentUserId: number; // ✅ 결재 상신한 사용자 ID
}) {
debugLog('[BiddingAwardHandler] 낙찰 핸들러 시작', {
@@ -506,25 +512,83 @@ export async function requestBiddingAwardInternal(payload: {
}
try {
- // 기존 awardBidding 함수 로직을 재구성하여 실행
- const { awardBidding } = await import('@/lib/bidding/detail/service');
-
- const result = await awardBidding(payload.biddingId, payload.selectionReason, payload.currentUserId.toString());
+ // ✅ 결재 상신 시점의 낙찰 대상 정보를 직접 사용하여 처리
+ const { default: db } = await import('@/db/db');
+ const { biddings, vendorSelectionResults } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
- if (!result.success) {
- debugError('[BiddingAwardHandler] 낙찰 처리 실패', result.error);
- throw new Error(result.error || '낙찰 처리에 실패했습니다.');
+ // 1. 최종입찰가 계산 (낙찰된 업체의 견적금액 * 발주비율의 합)
+ let finalBidPrice = 0;
+ for (const company of payload.awardedCompanies) {
+ finalBidPrice += company.finalQuoteAmount * (company.awardRatio / 100);
}
+ await db.transaction(async (tx) => {
+ // 1. 입찰 상태를 낙찰로 변경하고 최종입찰가 업데이트
+ await tx
+ .update(biddings)
+ .set({
+ status: 'vendor_selected',
+ finalBidPrice: finalBidPrice.toString(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, payload.biddingId));
+
+ // 2. 선정 사유 저장 (첫 번째 낙찰 업체 기준으로 저장)
+ const firstAwardedCompany = payload.awardedCompanies[0];
+
+ // 기존 선정 결과 확인
+ const existingResult = await tx
+ .select()
+ .from(vendorSelectionResults)
+ .where(eq(vendorSelectionResults.biddingId, payload.biddingId))
+ .limit(1);
+
+ if (existingResult.length > 0) {
+ // 업데이트
+ await tx
+ .update(vendorSelectionResults)
+ .set({
+ selectedCompanyId: firstAwardedCompany.companyId,
+ selectionReason: payload.selectionReason,
+ selectedBy: payload.currentUserId.toString(),
+ selectedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(vendorSelectionResults.biddingId, payload.biddingId));
+ } else {
+ // 삽입
+ await tx
+ .insert(vendorSelectionResults)
+ .values({
+ biddingId: payload.biddingId,
+ selectedCompanyId: firstAwardedCompany.companyId,
+ selectionReason: payload.selectionReason,
+ selectedBy: payload.currentUserId.toString(),
+ selectedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+ }
+ });
+
+ // 캐시 무효화 (API를 통한 방식)
+ const { revalidateViaCronJob } = await import('@/lib/revalidation-utils');
+ await revalidateViaCronJob({
+ tags: [`bidding-${payload.biddingId}`, 'quotation-vendors', 'quotation-details']
+ });
+
debugSuccess('[BiddingAwardHandler] 낙찰 완료', {
biddingId: payload.biddingId,
selectionReason: payload.selectionReason,
+ awardedCompaniesCount: payload.awardedCompanies.length,
+ finalBidPrice,
});
return {
success: true,
biddingId: payload.biddingId,
- message: `입찰이 낙찰 처리되었습니다.`,
+ message: `입찰이 낙찰 처리되었습니다. 최종입찰가: ${finalBidPrice.toLocaleString()}원`,
};
} catch (error) {
debugError('[BiddingAwardHandler] 낙찰 중 에러', error);
@@ -562,7 +626,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
targetPrice: biddings.targetPrice,
- winnerCount: biddings.winnerCount,
+ awardCount: biddings.awardCount,
})
.from(biddings)
.where(eq(biddings.id, biddingId))
@@ -606,7 +670,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const title = bidding.title || '낙찰';
const biddingTitle = bidding.title || '';
const biddingNumber = bidding.biddingNumber || '';
- const winnerCount = (bidding.winnerCount || 1).toString();
+ const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString();
const contractType = bidding.biddingType || '';
const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index d1a0d25c..521f4c33 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -32,7 +32,8 @@ import {
SQL,
like,
notInArray,
- inArray
+ inArray,
+ isNull
} from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
import { filterColumns } from '@/lib/filter-columns'
@@ -3603,6 +3604,83 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
})
}
+ // 11. 첨부파일 복제 (SHI용 및 협력업체용 첨부파일만)
+ const existingDocuments = await tx
+ .select()
+ .from(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, biddingId),
+ // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제)
+ isNull(biddingDocuments.prItemId),
+ // SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제
+ or(
+ eq(biddingDocuments.documentType, 'evaluation_doc'),
+ eq(biddingDocuments.documentType, 'company_proposal')
+ )
+ ))
+
+ if (existingDocuments.length > 0) {
+ for (const doc of existingDocuments) {
+ try {
+ // 기존 파일을 Buffer로 읽어서 File 객체 생성
+ const { readFileSync, existsSync } = await import('fs')
+ const { join } = await import('path')
+
+ const oldFilePath = doc.filePath.startsWith('/uploads/')
+ ? join(process.cwd(), 'public', doc.filePath)
+ : doc.filePath
+
+ if (!existsSync(oldFilePath)) {
+ console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`)
+ continue
+ }
+
+ // 파일 내용을 읽어서 Buffer 생성
+ const fileBuffer = readFileSync(oldFilePath)
+
+ // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션)
+ const file = new File([fileBuffer], doc.fileName, {
+ type: doc.mimeType || 'application/octet-stream'
+ })
+
+ // saveFile을 사용하여 새 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`,
+ originalName: `copied_${Date.now()}_${doc.fileName}`,
+ userId: userName
+ })
+
+ if (saveResult.success) {
+ // 새 첨부파일 레코드 삽입
+ await tx.insert(biddingDocuments).values({
+ biddingId: newBidding.id,
+ companyId: doc.companyId,
+ prItemId: null as any,
+ specificationMeetingId: null,
+ documentType: doc.documentType,
+ fileName: saveResult.fileName!,
+ originalFileName: doc.originalFileName,
+ fileSize: saveResult.fileSize!,
+ mimeType: doc.mimeType,
+ filePath: saveResult.publicPath!,
+ title: `${doc.documentType === 'evaluation_doc' ? 'SHI용' : '협력업체용'} 첨부파일 복제 - ${doc.originalFileName}`,
+ description: doc.description,
+ isPublic: doc.isPublic,
+ isRequired: doc.isRequired,
+ uploadedBy: userName,
+ uploadedAt: new Date(),
+ })
+ } else {
+ console.error(`첨부파일 저장 실패 (${doc.fileName}):`, saveResult.error)
+ }
+
+ } catch (error) {
+ console.error(`첨부파일 복제 실패 (${doc.fileName}):`, error)
+ }
+ }
+ }
+
revalidatePath('/bidding')
revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
revalidatePath(`/bidding/${newBidding.id}`)
@@ -3984,6 +4062,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
basicConditions.push(
or(
eq(biddings.status, 'bidding_disposal'),
+ eq(biddings.status, 'approval_pending'),
eq(biddings.status, 'bid_closure')
)!
)