From 6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 16 Sep 2025 09:20:58 +0000 Subject: (대표님, 최겸) 계약, 업로드 관련, 메뉴처리, 입찰, 프리쿼트, rfqLast관련, tbeLast관련 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/actions.ts | 226 +++++++++++++++++++++ lib/bidding/detail/service.ts | 14 +- .../table/bidding-detail-vendor-create-dialog.tsx | 4 +- .../detail/table/bidding-detail-vendor-table.tsx | 32 +-- .../bidding-detail-vendor-toolbar-actions.tsx | 68 ++++--- .../list/biddings-table-toolbar-actions.tsx | 100 +++++---- lib/bidding/list/biddings-transmission-dialog.tsx | 159 +++++++++++++++ lib/bidding/pre-quote/service.ts | 60 +++--- .../bidding-pre-quote-vendor-create-dialog.tsx | 4 +- lib/bidding/service.ts | 55 ++++- lib/bidding/vendor/partners-bidding-detail.tsx | 25 +++ .../vendor/partners-bidding-list-columns.tsx | 7 +- lib/bidding/vendor/partners-bidding-pre-quote.tsx | 33 ++- 13 files changed, 654 insertions(+), 133 deletions(-) create mode 100644 lib/bidding/actions.ts create mode 100644 lib/bidding/list/biddings-transmission-dialog.tsx (limited to 'lib/bidding') diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts new file mode 100644 index 00000000..9aabd469 --- /dev/null +++ b/lib/bidding/actions.ts @@ -0,0 +1,226 @@ +"use server" + +import db from "@/db/db" +import { eq, and } from "drizzle-orm" +import { + biddings, + biddingCompanies, + prItemsForBidding, + vendors, + generalContracts, + generalContractItems +} from "@/db/schema" +import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po" +import { getCurrentSAPDate } from "@/lib/soap/utils" + +// TO Contract 서버 액션 +export async function transmitToContract(biddingId: number, userId: number) { + console.log('=== transmitToContract STARTED ===') + console.log('biddingId:', biddingId, 'userId:', userId) + + try { + // 1. 입찰 정보 조회 (단순 쿼리) + console.log('Querying bidding...') + const bidding = await db.select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!bidding || bidding.length === 0) { + throw new Error("입찰 정보를 찾을 수 없습니다.") + } + + const biddingData = bidding[0] + console.log('biddingData', biddingData) + + // 2. 낙찰된 업체들 조회 (별도 쿼리) + console.log('Querying bidding companies...') + let winnerCompaniesData = [] + try { + // 2.1 biddingCompanies만 먼저 조회 (join 제거) + console.log('Step 1: Querying biddingCompanies only...') + const biddingCompaniesRaw = await db.select() + .from(biddingCompanies) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + ) + ) + + console.log('biddingCompaniesRaw:', biddingCompaniesRaw) + + // 2.2 각 company에 대한 vendor 정보 개별 조회 + for (const bc of biddingCompaniesRaw) { + console.log('Processing companyId:', bc.companyId) + + try { + const vendorData = await db.select() + .from(vendors) + .where(eq(vendors.id, bc.companyId)) + .limit(1) + + const vendor = vendorData.length > 0 ? vendorData[0] : null + console.log('Vendor data for', bc.companyId, ':', vendor) + + winnerCompaniesData.push({ + companyId: bc.companyId, + finalQuoteAmount: bc.finalQuoteAmount, + vendorCode: vendor?.vendorCode || null, + vendorName: vendor?.vendorName || null, + }) + } catch (vendorError) { + console.error('Vendor query error for', bc.companyId, ':', vendorError) + // vendor 정보 없이도 진행 + winnerCompaniesData.push({ + companyId: bc.companyId, + finalQuoteAmount: bc.finalQuoteAmount, + vendorCode: null, + vendorName: null, + }) + } + } + + console.log('winnerCompaniesData type:', typeof winnerCompaniesData) + console.log('winnerCompaniesData length:', winnerCompaniesData?.length) + console.log('winnerCompaniesData:', winnerCompaniesData) + } catch (queryError) { + console.error('Query error:', queryError) + throw new Error(`biddingCompanies 쿼리 실패: ${queryError}`) + } + + // 상태 검증 + console.log('biddingData.status', biddingData.status) + if (biddingData.status !== 'vendor_selected') { + throw new Error("업체 선정이 완료되지 않은 입찰입니다.") + } + + // 낙찰된 업체 검증 + if (winnerCompaniesData.length === 0) { + throw new Error("낙찰된 업체가 없습니다.") + } + + console.log('Processing', winnerCompaniesData.length, 'winner companies') + for (const winnerCompany of winnerCompaniesData) { + // 계약 번호 자동 생성 (현재 시간 기반) + const contractNumber = `CONTRACT-BID-${Date.now()}-${winnerCompany.companyId}` + console.log('contractNumber', contractNumber) + // general-contract 생성 + const contractResult = await db.insert(generalContracts).values({ + contractNumber, + revision: 0, + contractSourceType: 'bid', // 입찰에서 생성됨 + status: 'Draft', + category: biddingData.contractType as any, // 단가계약, 일반계약, 매각계약 + name: biddingData.title, + selectionMethod: '입찰', + vendorId: winnerCompany.companyId, + linkedBidNumber: biddingData.biddingNumber, + contractAmount: winnerCompany.finalQuoteAmount || undefined, + currency: biddingData.currency || 'KRW', + registeredById: userId, // TODO: 현재 사용자 ID로 변경 필요 + lastUpdatedById: userId, // TODO: 현재 사용자 ID로 변경 필요 + }).returning({ id: generalContracts.id }) + console.log('contractResult', contractResult) + const contractId = contractResult[0].id + + // 3. PR 아이템들로 general-contract-items 생성 (일단 생략) + console.log('Skipping PR items creation for now') + } + + return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` } + + } catch (error) { + console.error('TO Contract 실패:', error) + throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.') + } +} + +// TO PO 서버 액션 +export async function transmitToPO(biddingId: number) { + try { + // 1. 입찰 정보 및 낙찰 업체 조회 + const bidding = await db.query.biddings.findFirst({ + where: eq(biddings.id, biddingId), + with: { + biddingCompanies: { + where: eq(biddingCompanies.isWinner, true), // 낙찰된 업체만 + with: { + vendor: true + } + }, + prItemsForBidding: true + } + }) + + if (!bidding) { + throw new Error("입찰 정보를 찾을 수 없습니다.") + } + + if (bidding.status !== 'vendor_selected') { + throw new Error("업체 선정이 완료되지 않은 입찰입니다.") + } + + const winnerCompanies = bidding.biddingCompanies.filter(bc => bc.isWinner) + + if (winnerCompanies.length === 0) { + throw new Error("낙찰된 업체가 없습니다.") + } + + // 2. PO 데이터 구성 + const poData = { + T_Bidding_HEADER: winnerCompanies.map((company, index) => ({ + ANFNR: bidding.biddingNumber, + LIFNR: company.vendor?.vendorCode || `VENDOR${company.companyId}`, + ZPROC_IND: 'A', // 구매 처리 상태 + ANGNR: bidding.biddingNumber, + WAERS: bidding.currency || 'KRW', + ZTERM: '0001', // 기본 지급조건 + INCO1: 'FOB', + INCO2: 'Seoul, Korea', + MWSKZ: 'V0', // 세금 코드 + LANDS: 'KR', + ZRCV_DT: getCurrentSAPDate(), + ZATTEN_IND: 'Y', + IHRAN: getCurrentSAPDate(), + TEXT: `PO from Bidding: ${bidding.title}`, + })), + T_Bidding_ITEM: bidding.prItemsForBidding?.map((item, index) => ({ + ANFNR: bidding.biddingNumber, + ANFPS: (index + 1).toString().padStart(5, '0'), + LIFNR: winnerCompanies[0]?.vendor?.vendorCode || `VENDOR${winnerCompanies[0]?.companyId}`, + NETPR: item.annualUnitPrice?.toString() || '0', + PEINH: '1', + BPRME: item.quantityUnit || 'EA', + NETWR: item.annualUnitPrice && item.quantity + ? (item.annualUnitPrice * item.quantity).toString() + : '0', + BRTWR: item.annualUnitPrice && item.quantity + ? ((item.annualUnitPrice * item.quantity) * 1.1).toString() // 10% 부가세 가정 + : '0', + LFDAT: item.requestedDeliveryDate?.toISOString().split('T')[0] || getCurrentSAPDate(), + })) || [], + T_PR_RETURN: [{ + ANFNR: bidding.biddingNumber, + ANFPS: '00001', + EBELN: `PR${bidding.biddingNumber}`, + EBELP: '00001', + MSGTY: 'S', + MSGTXT: 'Success' + }] + } + + // 3. SAP으로 PO 전송 + const result = await createPurchaseOrder(poData) + + if (!result.success) { + throw new Error(result.message) + } + + return { success: true, message: result.message } + + } catch (error) { + console.error('TO PO 실패:', error) + throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.') + } +} diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index df8427da..b00a4f4f 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -632,7 +632,7 @@ export async function createBiddingDetailVendor( companyId: vendorId, invitationStatus: 'pending', isPreQuoteSelected: true, // 본입찰 등록 기본값 - isWinner: false, + isWinner: null, // 미정 상태로 초기화 0916 createdAt: new Date(), updatedAt: new Date(), }).returning({ id: biddingCompanies.id }) @@ -679,13 +679,23 @@ export async function createQuotationVendor(input: any, userId: string) { try { const userName = await getUserNameById(userId) const result = await db.transaction(async (tx) => { + // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인 + const existingCompany = await tx + .select() + .from(biddingCompanies) + .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`) + + if (existingCompany.length > 0) { + throw new Error('이미 등록된 업체입니다') + } + // 1. biddingCompanies에 레코드 생성 const biddingCompanyResult = await tx.insert(biddingCompanies).values({ biddingId: input.biddingId, companyId: input.vendorId, finalQuoteAmount: input.quotationAmount?.toString(), awardRatio: input.awardRatio?.toString(), - isWinner: false, + isWinner: null, // 미정 상태로 초기화 contactPerson: input.contactPerson, contactEmail: input.contactEmail, contactPhone: input.contactPhone, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index 5e85af06..f35957bc 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -36,7 +36,7 @@ import { import { Check, ChevronsUpDown, Search } from 'lucide-react' import { cn } from '@/lib/utils' import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendors } from '@/lib/vendors/service' +import { searchVendorsForBidding } from '@/lib/bidding/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -83,7 +83,7 @@ export function BiddingDetailVendorCreateDialog({ } try { - const result = await searchVendors(vendorSearchValue.trim(), 10) + const result = await searchVendorsForBidding(vendorSearchValue.trim(), biddingId, 10) setVendors(result) } catch (error) { console.error('Vendor search failed:', error) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index 95f63ce9..a9778636 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -20,7 +20,7 @@ import { } from '@/lib/bidding/detail/service' import { selectWinnerSchema } from '@/lib/bidding/validation' import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' +import { useTransition, useCallback } from 'react' interface BiddingDetailVendorTableContentProps { biddingId: number @@ -114,29 +114,7 @@ export function BiddingDetailVendorTableContent({ const [priceAdjustmentData, setPriceAdjustmentData] = React.useState(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) - const handleDelete = (vendor: QuotationVendor) => { - if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return - - startTransition(async () => { - const response = await deleteQuotationVendor(vendor.id) - - if (response.success) { - toast({ - title: '성공', - description: response.message, - }) - onRefresh() - } else { - toast({ - title: '오류', - description: response.error, - variant: 'destructive', - }) - } - }) - } - - const handleSelectWinner = (vendor: QuotationVendor) => { + const handleSelectWinner = useCallback((vendor: QuotationVendor) => { if (!vendor.awardRatio || vendor.awardRatio <= 0) { toast({ title: '오류', @@ -180,7 +158,7 @@ export function BiddingDetailVendorTableContent({ }) } }) - } + }, [toast, startTransition, biddingId, userId, selectWinnerSchema, selectWinner, onRefresh]) const handleEdit = (vendor: QuotationVendor) => { setSelectedVendor(vendor) @@ -214,12 +192,12 @@ export function BiddingDetailVendorTableContent({ const columns = React.useMemo( () => getBiddingDetailVendorColumns({ onEdit: onEdit || handleEdit, - onDelete: onDelete || handleDelete, + onDelete: onDelete, onSelectWinner: onSelectWinner || handleSelectWinner, onViewPriceAdjustment: handleViewPriceAdjustment, onViewItemDetails: onViewItemDetails }), - [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails] + [onEdit, onDelete, onSelectWinner, handleEdit, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails] ) const { table } = useDataTable({ 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 0b707944..893fb185 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -180,6 +180,8 @@ export function BiddingDetailVendorToolbarActions({ <>
{/* 상태별 액션 버튼 */} + {bidding.status !== 'bidding_closed' && bidding.status !== 'vendor_selected' && ( + <> */} - - - - + + + + + + + )}
@@ -28,7 +29,11 @@ interface BiddingsTableToolbarActionsProps { export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incotermsOptions }: BiddingsTableToolbarActionsProps) { const router = useRouter() + const { data: session } = useSession() const [isExporting, setIsExporting] = React.useState(false) + const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false) + + const userId = session?.user?.id ? Number(session.user.id) : 1 // 선택된 입찰들 const selectedBiddings = React.useMemo(() => { @@ -38,6 +43,9 @@ export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incote .map(row => row.original) }, [table.getFilteredSelectedRowModel().rows]) + // 업체선정이 완료된 입찰만 전송 가능 + const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected' + const handleExport = async () => { try { setIsExporting(true) @@ -54,47 +62,69 @@ export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incote } return ( -
- {/* 신규 생성 */} - + <> +
+ {/* 신규 생성 */} + - {/* 개찰 (입찰 오픈) */} - {/* {openEligibleBiddings.length > 0 && ( - - )} */} - {/* Export */} - - + {/* 개찰 (입찰 오픈) */} + {/* {openEligibleBiddings.length > 0 && ( - - - - - 입찰 목록 내보내기 - - - -
+ )} */} + + {/* Export */} + + + + + + + + 입찰 목록 내보내기 + + + +
+ + {/* 전송 다이얼로그 */} + + ) } \ No newline at end of file diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx new file mode 100644 index 00000000..d307ec9d --- /dev/null +++ b/lib/bidding/list/biddings-transmission-dialog.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import { + Send, CheckCircle, FileText, Truck +} from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { BiddingListItem } from "@/db/schema" +import { transmitToContract, transmitToPO } from "@/lib/bidding/actions" + +console.log('=== Module loaded ===') +console.log('transmitToContract imported:', typeof transmitToContract) +console.log('transmitToPO imported:', typeof transmitToPO) + +interface TransmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bidding: BiddingListItem | undefined + userId: number +} + +export function TransmissionDialog({ open, onOpenChange, bidding, userId }: TransmissionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + if (!bidding) return null + + const handleToContract = async () => { + try { + setIsLoading(true) + console.log('=== START handleToContract ===') + console.log('bidding.id', bidding.id) + console.log('userId', userId) + console.log('transmitToContract function:', typeof transmitToContract) + + console.log('About to call transmitToContract...') + const result = await transmitToContract(bidding.id, userId) + console.log('transmitToContract result:', result) + + toast.success('계약서 생성이 완료되었습니다.') + onOpenChange(false) + } catch (error) { + console.error('handleToContract error:', error) + toast.error(`계약서 생성에 실패했습니다: ${error}`) + } finally { + setIsLoading(false) + console.log('=== END handleToContract ===') + } + } + + const handleToPO = async () => { + try { + setIsLoading(true) + await transmitToPO(bidding.id) + toast.success('PO 전송이 완료되었습니다.') + onOpenChange(false) + } catch (error) { + toast.error(`PO 전송에 실패했습니다: ${error}`) + } finally { + setIsLoading(false) + } + } + + return ( + + + + + + 입찰 전송 + + + 선택된 입찰을 계약서 또는 PO로 전송합니다. + + + +
+ {/* 입찰 정보 */} + + + 입찰 정보 + + +
+
+ +

{bidding.biddingNumber}

+
+
+ +

{bidding.title}

+
+
+ +

{bidding.contractType}

+
+
+ +

+ {bidding.budget ? `${bidding.budget.toLocaleString()} ${bidding.currency}` : '-'} +

+
+
+
+
+ + {/* 선정된 업체 정보 (임시로 표시) */} + + + 선정된 업체 + + +
+ + 업체 선정이 완료되었습니다. +
+

+ 자세한 업체 정보는 전송 후 확인할 수 있습니다. +

+
+
+
+ + + + + + +
+
+ ) +} diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 7f054a66..cad77a6b 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -6,12 +6,12 @@ import { basicContractTemplates } from '@/db/schema' import { vendors } from '@/db/schema/vendors' import { users } from '@/db/schema' import { sendEmail } from '@/lib/mail/sendEmail' -import { eq, inArray, and, ilike } from 'drizzle-orm' +import { eq, inArray, and, ilike, sql } from 'drizzle-orm' import { mkdir, writeFile } from 'fs/promises' import path from 'path' import { revalidateTag, revalidatePath } from 'next/cache' import { basicContract } from '@/db/schema/basicContractDocumnet' -import { saveFile ,saveBuffer} from '@/lib/file-stroage' +import { saveFile } from '@/lib/file-stroage' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise { @@ -69,6 +69,15 @@ interface PreQuoteDocumentUpload { export async function createBiddingCompany(input: CreateBiddingCompanyInput) { try { const result = await db.transaction(async (tx) => { + // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인 + const existingCompany = await tx + .select() + .from(biddingCompanies) + .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`) + + if (existingCompany.length > 0) { + throw new Error('이미 등록된 업체입니다') + } // 1. biddingCompanies 레코드 생성 const biddingCompanyResult = await tx.insert(biddingCompanies).values({ biddingId: input.biddingId, @@ -1225,7 +1234,10 @@ export async function sendBiddingBasicContracts( const results = [] const savedContracts = [] - // 트랜잭션 시작 - contractsDir 제거 (saveBuffer가 처리) + // 트랜잭션 시작 + const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated"); + await mkdir(contractsDir, { recursive: true }); + const result = await db.transaction(async (tx) => { // 각 벤더별로 기본계약 생성 및 이메일 발송 for (const vendor of vendorData) { @@ -1285,7 +1297,6 @@ export async function sendBiddingBasicContracts( if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' }) if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' }) console.log("contractTypes", contractTypes) - for (const contractType of contractTypes) { // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기) console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key)) @@ -1299,22 +1310,11 @@ export async function sendBiddingBasicContracts( continue } - // 파일 저장 - saveBuffer 사용 + // 파일 저장 (rfq-last 방식) const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf` - - const saveResult = await saveBuffer({ - buffer: Buffer.from(pdfData.buffer), - fileName: fileName, - directory: 'contracts/generated', - originalName: fileName, - userId: currentUser.id - }) + const filePath = path.join(contractsDir, fileName); - // 저장 실패 시 처리 - if (!saveResult.success) { - console.error(`PDF 저장 실패: ${saveResult.error}`) - continue - } + await writeFile(filePath, Buffer.from(pdfData.buffer)); // 템플릿 정보 조회 (rfq-last 방식) const [template] = await db @@ -1352,8 +1352,8 @@ export async function sendBiddingBasicContracts( .set({ requestedBy: currentUser.id, status: "PENDING", // 재발송 상태 - fileName: saveResult.originalName || fileName, // 원본 파일명 - filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), updatedAt: new Date(), }) @@ -1373,8 +1373,8 @@ export async function sendBiddingBasicContracts( generalContractId: null, requestedBy: currentUser.id, status: 'PENDING', - fileName: saveResult.originalName || fileName, // 원본 파일명 - filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후 createdAt: new Date(), updatedAt: new Date(), @@ -1389,10 +1389,19 @@ export async function sendBiddingBasicContracts( vendorName: vendor.vendorName, contractId: contractRecord.id, contractType: contractType.type, - fileName: saveResult.originalName || fileName, - filePath: saveResult.publicPath, - hashedFileName: saveResult.fileName, // 실제 저장된 파일명 (디버깅용) + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, }) + + // savedContracts에 추가 (rfq-last 방식) + // savedContracts.push({ + // vendorId: vendor.vendorId, + // vendorName: vendor.vendorName, + // templateName: contractType.templateName, + // contractId: contractRecord.id, + // fileName: fileName, + // isUpdated: !!existingContract, // 업데이트 여부 표시 + // }) } // 이메일 발송 (선택사항) @@ -1439,6 +1448,7 @@ export async function sendBiddingBasicContracts( ) } } + // 기존 기본계약 조회 (서버 액션) export async function getExistingBasicContractsForBidding(biddingId: number) { try { diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx index e2a38547..bc233e77 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -26,7 +26,7 @@ import { import { Check, ChevronsUpDown } from 'lucide-react' import { cn } from '@/lib/utils' import { createBiddingCompany } from '@/lib/bidding/pre-quote/service' -import { searchVendors } from '@/lib/vendors/service' +import { searchVendorsForBidding } from '@/lib/bidding/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -69,7 +69,7 @@ export function BiddingPreQuoteVendorCreateDialog({ } try { - const result = await searchVendors(vendorSearchValue.trim(), 10) + const result = await searchVendorsForBidding(vendorSearchValue.trim(), biddingId, 10) setVendors(result) } catch (error) { console.error('Vendor search failed:', error) diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 90a379e1..55146c4b 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -14,7 +14,10 @@ import { users, basicContractTemplates, paymentTerms, - incoterms + incoterms, + vendors, + vendorsWithTypesView, + biddingCompanies } from '@/db/schema' import { eq, @@ -556,6 +559,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { finalBidPrice: input.finalBidPrice ? parseFloat(input.finalBidPrice) : null, status: input.status || 'bidding_generated', + // biddingSourceType: input.biddingSourceType || 'manual', isPublic: input.isPublic || false, isUrgent: input.isUrgent || false, managerName: input.managerName, @@ -1421,4 +1425,53 @@ export async function getActiveContractTemplates() { console.error('활성 템플릿 조회 실패:', error); throw new Error('템플릿 조회에 실패했습니다.'); } +} + +// 입찰에 참여하지 않은 벤더만 검색 (중복 방지) +export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number, limit: number = 100) { + try { + let whereCondition; + + if (searchTerm.trim()) { + const s = `%${searchTerm.trim()}%`; + whereCondition = or( + ilike(vendorsWithTypesView.vendorName, s), + ilike(vendorsWithTypesView.vendorCode, s) + ); + } + + // 이미 해당 입찰에 참여중인 벤더 ID들을 가져옴 + const participatingVendorIds = await db + .select({ companyId: biddingCompanies.companyId }) + .from(biddingCompanies) + .where(eq(biddingCompanies.biddingId, biddingId)); + + const excludedIds = participatingVendorIds.map(p => p.companyId); + + const result = await db + .select({ + id: vendorsWithTypesView.id, + vendorName: vendorsWithTypesView.vendorName, + vendorCode: vendorsWithTypesView.vendorCode, + status: vendorsWithTypesView.status, + country: vendorsWithTypesView.country, + }) + .from(vendorsWithTypesView) + .where( + and( + whereCondition, + // 이미 참여중인 벤더 제외 + excludedIds.length > 0 ? sql`${vendorsWithTypesView.id} NOT IN (${excludedIds})` : undefined, + // ACTIVE 상태인 벤더만 검색 + // eq(vendorsWithTypesView.status, "ACTIVE"), + ) + ) + .orderBy(asc(vendorsWithTypesView.vendorName)) + .limit(limit); + + return result; + } catch (error) { + console.error('Error searching vendors for bidding:', error) + return [] + } } \ No newline at end of file diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 4b316eee..89ca426b 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -296,6 +296,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSaveDraft = async () => { if (!biddingDetail || !userId) return + // 입찰 마감 상태 체크 + const biddingStatus = biddingDetail.status + const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' + + if (isClosed) { + toast({ + title: "접근 제한", + description: "입찰이 마감되어 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } + if (prItemQuotations.length === 0) { toast({ title: '저장할 데이터 없음', @@ -350,6 +363,18 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSubmitResponse = () => { if (!biddingDetail) return + // 입찰 마감 상태 체크 + const biddingStatus = biddingDetail.status + const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' + + if (isClosed) { + toast({ + title: "접근 제한", + description: "입찰이 마감되어 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } // 필수값 검증 if (!responseData.finalQuoteAmount.trim()) { diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 431f7e9a..534e8838 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -177,7 +177,10 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }) } } - + + const biddingStatus = row.original.status + const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' + return ( @@ -194,10 +197,12 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL 입찰 상세보기 + {!isClosed && ( 사전견적하기 + )} ) diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx index bdc860f4..4ec65413 100644 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -254,6 +254,19 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin }) return } + // 입찰 마감 상태 체크 + const biddingStatus = biddingDetail.status + const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' + + if (isClosed) { + toast({ + title: "접근 제한", + description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.", + variant: "destructive", + }) + router.back() + return + } if (!userId) { toast({ @@ -356,6 +369,20 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin const handleSubmitResponse = () => { if (!biddingDetail) return + // 입찰 마감 상태 체크 + const biddingStatus = biddingDetail.status + const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' + + if (isClosed) { + toast({ + title: "접근 제한", + description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.", + variant: "destructive", + }) + router.back() + return + } + // 견적마감일 체크 if (biddingDetail.preQuoteDeadline) { const now = new Date() @@ -519,12 +546,6 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin }) } - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: biddingDetail?.currency || 'KRW', - }).format(amount) - } if (isLoading) { return ( -- cgit v1.2.3