From f93493f68c9f368e10f1c3379f1c1384068e3b14 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Sep 2025 10:29:19 +0000 Subject: (대표님, 최겸) rfqLast, bidding, prequote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 1481 ++++++++++++++++---- lib/bidding/detail/table/bidding-award-dialog.tsx | 259 ++++ .../detail/table/bidding-detail-content.tsx | 15 +- .../bidding-detail-selection-reason-dialog.tsx | 167 --- .../table/bidding-detail-target-price-dialog.tsx | 178 ++- .../detail/table/bidding-detail-vendor-columns.tsx | 171 +-- .../table/bidding-detail-vendor-create-dialog.tsx | 221 +-- .../table/bidding-detail-vendor-edit-dialog.tsx | 289 +--- .../detail/table/bidding-detail-vendor-table.tsx | 12 + .../bidding-detail-vendor-toolbar-actions.tsx | 102 +- .../table/components/award-simple-file-upload.tsx | 307 ++++ lib/bidding/list/biddings-table-columns.tsx | 2 +- lib/bidding/list/create-bidding-dialog.tsx | 31 + lib/bidding/pre-quote/service.ts | 64 +- .../table/bidding-pre-quote-selection-dialog.tsx | 158 +++ .../table/bidding-pre-quote-vendor-columns.tsx | 4 +- .../bidding-pre-quote-vendor-toolbar-actions.tsx | 37 +- lib/bidding/service.ts | 4 +- .../vendor/components/pr-items-pricing-table.tsx | 23 +- lib/bidding/vendor/partners-bidding-detail.tsx | 821 +++++------ lib/bidding/vendor/partners-bidding-list.tsx | 13 + lib/bidding/vendor/partners-bidding-pre-quote.tsx | 4 +- lib/mail/templates/bidding-disposal.hbs | 55 + lib/mail/templates/bidding-invitation.hbs | 63 + lib/mail/templates/rebidding-invitation.hbs | 66 + lib/rfq-last/service.ts | 480 ++++++- .../vendor/batch-update-conditions-dialog.tsx | 81 +- lib/rfq-last/vendor/rfq-vendor-table.tsx | 208 ++- lib/rfq-last/vendor/send-rfq-dialog.tsx | 578 ++++++++ .../vendor-regular-registrations-table-columns.tsx | 4 +- 30 files changed, 4270 insertions(+), 1628 deletions(-) create mode 100644 lib/bidding/detail/table/bidding-award-dialog.tsx delete mode 100644 lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx create mode 100644 lib/bidding/detail/table/components/award-simple-file-upload.tsx create mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx create mode 100644 lib/mail/templates/bidding-disposal.hbs create mode 100644 lib/mail/templates/bidding-invitation.hbs create mode 100644 lib/mail/templates/rebidding-invitation.hbs create mode 100644 lib/rfq-last/vendor/send-rfq-dialog.tsx (limited to 'lib') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 7c7ae498..956c1798 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -4,43 +4,50 @@ import db from '@/db/db' import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema' import { specificationMeetings } from '@/db/schema/bidding' import { eq, and, sql, desc, ne } from 'drizzle-orm' -import { revalidatePath } from 'next/cache' +import { revalidatePath, revalidateTag } from 'next/cache' +import { unstable_cache } from "@/lib/unstable-cache"; +import { sendEmail } from '@/lib/mail/sendEmail' +import { saveFile } from '@/lib/file-stroage' // 데이터 조회 함수들 export interface BiddingDetailData { bidding: Awaited> quotationDetails: QuotationDetails | null quotationVendors: QuotationVendor[] - biddingCompanies: Awaited> prItems: Awaited> } // getBiddingById 함수 임포트 (기존 함수 재사용) import { getBiddingById, getPRDetailsAction } from '@/lib/bidding/service' -// Promise.all을 사용하여 모든 데이터를 병렬로 조회 +// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용) export async function getBiddingDetailData(biddingId: number): Promise { - const [ - bidding, - quotationDetails, - quotationVendors, - biddingCompanies, - prItems - ] = await Promise.all([ - getBiddingById(biddingId), - getQuotationDetails(biddingId), - getQuotationVendors(biddingId), - getBiddingCompaniesData(biddingId), - getPRItemsForBidding(biddingId) - ]) + return unstable_cache( + async () => { + const [ + bidding, + quotationDetails, + quotationVendors, + prItems + ] = await Promise.all([ + getBiddingById(biddingId), + getQuotationDetails(biddingId), + getQuotationVendors(biddingId), + getPRItemsForBidding(biddingId) + ]) - return { - bidding, - quotationDetails, - quotationVendors, - biddingCompanies, - prItems - } + return { + bidding, + quotationDetails, + quotationVendors, + prItems + } + }, + [`bidding-detail-data-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items'] + } + )() } export interface QuotationDetails { biddingId: number @@ -65,18 +72,9 @@ export interface QuotationVendor { currency: string submissionDate: string // 제출일 isWinner: boolean // 낙찰여부 - awardRatio: number // 발주비율 + awardRatio: number | null // 발주비율 + isBiddingParticipated: boolean | null // 본입찰 참여여부 status: 'pending' | 'submitted' | 'selected' | 'rejected' - // companyConditionResponses에서 가져온 입찰 조건들 - paymentTermsResponse?: string // 지급조건 응답 - taxConditionsResponse?: string // 세금조건 응답 - incotermsResponse?: string // 운송조건 응답 - proposedContractDeliveryDate?: string // 제안 계약납기일 - proposedShippingPort?: string // 제안 선적지 - proposedDestinationPort?: string // 제안 도착지 - priceAdjustmentResponse?: boolean // 연동제 적용 응답 - sparePartResponse?: string // 스페어파트 응답 - additionalProposals?: string // 추가 제안사항 documents: Array<{ id: number fileName: string @@ -86,8 +84,10 @@ export interface QuotationVendor { }> } -// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 +// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용) export async function getQuotationDetails(biddingId: number): Promise { + return unstable_cache( + async () => { try { // bidding_companies 테이블에서 견적 데이터를 집계 const quotationStats = await db @@ -136,6 +136,12 @@ export async function getQuotationDetails(biddingId: number): Promise { + try { + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + .orderBy(prItemsForBidding.id) + + return items + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } + }, + [`pr-items-for-bidding-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'pr-items'] + } + )() } -// 견적 시스템에서 협력업체 정보를 가져오는 함수 +// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용) export async function getQuotationVendors(biddingId: number): Promise { + return unstable_cache( + async () => { try { - // bidding_companies 테이블을 메인으로 vendors, company_condition_responses를 조인하여 협력업체 정보 조회 + // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회 const vendorsData = await db .select({ id: biddingCompanies.id, @@ -207,32 +229,25 @@ export async function getQuotationVendors(biddingId: number): Promise`'KRW'` as currency, + currency: sql`'KRW'`, submissionDate: biddingCompanies.finalQuoteSubmittedAt, isWinner: biddingCompanies.isWinner, - awardRatio: sql`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, + // awardRatio: sql`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, + awardRatio: biddingCompanies.awardRatio, + isBiddingParticipated: biddingCompanies.isBiddingParticipated, status: sql`CASE WHEN ${biddingCompanies.isWinner} THEN 'selected' WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted' WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted' ELSE 'pending' END`, - // companyConditionResponses에서 입찰 조건들 - paymentTermsResponse: companyConditionResponses.paymentTermsResponse, - taxConditionsResponse: companyConditionResponses.taxConditionsResponse, - incotermsResponse: companyConditionResponses.incotermsResponse, - proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, - proposedShippingPort: companyConditionResponses.proposedShippingPort, - proposedDestinationPort: companyConditionResponses.proposedDestinationPort, - priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, - isInitialResponse: companyConditionResponses.isInitialResponse, - sparePartResponse: companyConditionResponses.sparePartResponse, - additionalProposals: companyConditionResponses.additionalProposals, }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)) - .where(eq(biddingCompanies.biddingId, biddingId)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회 + )) .orderBy(desc(biddingCompanies.finalQuoteAmount)) return vendorsData.map(vendor => ({ @@ -246,27 +261,272 @@ export async function getQuotationVendors(biddingId: number): Promise 0` + )) + .orderBy(biddingCompanies.preQuoteAmount) + + if (preQuotes.length === 0) { + return { + quotes: [], + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } + + const amounts = preQuotes + .map(q => Number(q.preQuoteAmount)) + .filter(amount => !isNaN(amount) && amount > 0) + + console.log('Pre-quote amounts:', amounts) + + if (amounts.length === 0) { + return { + quotes: preQuotes, + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } + + const lowestQuote = Math.min(...amounts) + const highestQuote = Math.max(...amounts) + const averageQuote = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length + + console.log('Calculated quotes:', { lowestQuote, highestQuote, averageQuote }) + + return { + quotes: preQuotes, + lowestQuote, + highestQuote, + averageQuote, + quotationCount: amounts.length + } + } catch (error) { + console.error('Failed to get pre-quote data:', error) + return { + quotes: [], + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } +} + +// 입찰유형별 내정가 자동 산정 로직 +export async function calculateTargetPrice( + biddingType: string, + budget: number | null, + lowestQuote: number | null, + highestQuote: number | null +): Promise<{ targetPrice: number; criteria: string }> { + const results: Array<{ price: number; description: string }> = [] + + // 입찰유형별 로직 + switch (biddingType) { + case 'equipment': + case 'construction': + case 'service': + case 'lease': + case 'steel_stock': + case 'piping': { + // 예산가 85%, 최저견적가 85% 중 최저가 (직전실적가 95% 제외) + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } + break + } + case 'transport': { + // 예산가 85%, 최저견적가 85% 중 최저가 (직전실적가 95% 제외) + // 만약 예산이 없을 경우 최저견적가의 70% + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + if (budget) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } else { + results.push({ price: lowestQuote * 0.70, description: '최저견적가 70% (예산 없음)' }) + } + } + break + } + case 'waste': { + // 예산가 85%, 최저견적가 70% 중 최저가 (직전실적가 95% 제외) + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + results.push({ price: lowestQuote * 0.70, description: '최저견적가 70%' }) + } + break + } + case 'sale': { + // 최고견적가 130% (직전실적가 105% 제외) + if (highestQuote) { + results.push({ price: highestQuote * 1.30, description: '최고견적가 130%' }) + } + break + } + default: { + // 기본: 최저견적가 85% + if (lowestQuote) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } + break + } + } + + if (results.length === 0) { + return { + targetPrice: 0, + criteria: '산정 가능한 데이터가 없습니다.' + } + } + + // 매각의 경우 최고가, 나머지는 최저가 + const prices = results.map(r => r.price).filter(p => !isNaN(p) && isFinite(p)) + + if (prices.length === 0) { + return { + targetPrice: 0, + criteria: '유효한 가격 데이터가 없습니다.' + } + } + + const targetPrice = biddingType === 'sale' + ? Math.max(...prices) + : Math.min(...prices) + + if (!isFinite(targetPrice) || isNaN(targetPrice)) { + return { + targetPrice: 0, + criteria: '내정가 계산 오류가 발생했습니다.' + } + } + + const selectedResult = results.find(r => r.price === targetPrice) + const criteria = `입찰유형: ${biddingType} - ${selectedResult?.description || ''}로 산정` + + return { + targetPrice: Math.round(targetPrice), + criteria + } +} + +// 내정가 자동 산정 및 업데이트 +export async function calculateAndUpdateTargetPrice( + biddingId: number, + userId: string +) { + try { + // 입찰 정보 조회 + const bidding = await getBiddingById(biddingId) + if (!bidding) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + // 사전견적 데이터 조회 + const preQuoteData = await getPreQuoteData(biddingId) + + if (preQuoteData.quotationCount === 0) { + return { success: false, error: '사전견적 데이터가 없습니다.' } + } + + // 내정가 산정 + console.log('Bidding data for calculation:', { + biddingType: bidding.biddingType, + budget: bidding.budget, + preQuoteData + }) + + const { targetPrice, criteria } = await calculateTargetPrice( + bidding.biddingType || '', + bidding.budget ? Number(bidding.budget) : null, + preQuoteData.lowestQuote, + preQuoteData.highestQuote + ) + + console.log('Calculated target price:', { targetPrice, criteria }) + + if (!targetPrice || targetPrice <= 0 || isNaN(targetPrice)) { + return { success: false, error: `내정가 산정에 실패했습니다. (계산된 값: ${targetPrice})` } + } + + // 내정가 업데이트 + const updateResult = await updateTargetPrice(biddingId, targetPrice, criteria, userId) + + if (updateResult.success) { + // 내정가 산정 후 입찰 상태를 set_target_price로 변경 (received_quotation 상태에서만) + await db + .update(biddings) + .set({ + status: 'set_target_price', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'received_quotation') + )) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + + return { + success: true, + message: '내정가가 자동으로 산정되었습니다.', + data: { + targetPrice, + criteria, + preQuoteData + } + } + } else { + return updateResult + } + } catch (error) { + console.error('Failed to calculate and update target price:', error) + return { success: false, error: '내정가 자동 산정에 실패했습니다.' } + } } // 내정가 수동 업데이트 (실제 저장) @@ -277,15 +537,25 @@ export async function updateTargetPrice( userId: string ) { try { + // 입력값 검증 + if (!targetPrice || targetPrice <= 0 || isNaN(targetPrice)) { + return { success: false, error: `유효하지 않은 내정가입니다: ${targetPrice}` } + } + + console.log('Updating target price:', { biddingId, targetPrice, targetPriceCalculationCriteria }) + await db .update(biddings) .set({ - targetPrice: targetPrice.toString(), + targetPrice: Math.round(targetPrice).toString(), targetPriceCalculationCriteria: targetPriceCalculationCriteria, updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '내정가가 성공적으로 업데이트되었습니다.' } } catch (error) { @@ -294,6 +564,103 @@ export async function updateTargetPrice( } } +// 본입찰용 업체 수정 (간소화 버전 - 발주비율만 UI에서 수정 가능, 견적금액/통화는 기존값 유지) +export async function updateBiddingDetailVendor( + biddingCompanyId: number, + quotationAmount: number, // 기존값 유지용 + currency: string, // 기존값 유지용 + awardRatio: number, // UI에서 수정 가능 + userId: string +) { + try { + const result = await db.update(biddingCompanies) + .set({ + finalQuoteAmount: quotationAmount.toString(), + awardRatio: awardRatio.toString(), + isWinner: awardRatio > 0, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { + success: true, + message: '업체 정보가 성공적으로 수정되었습니다.', + } + } catch (error) { + console.error('Failed to update bidding detail vendor:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 정보 수정에 실패했습니다.' + } + } +} + +// 본입찰용 업체 추가 +export async function createBiddingDetailVendor( + biddingId: number, + vendorId: number, + userId: string +) { + try { + const result = await db.transaction(async (tx) => { + // 1. biddingCompanies 레코드 생성 (본입찰 선정 기본값 true) + const biddingCompanyResult = await tx.insert(biddingCompanies).values({ + biddingId: biddingId, + companyId: vendorId, + invitationStatus: 'pending', + isPreQuoteSelected: true, // 본입찰 등록 기본값 + isWinner: false, + createdAt: new Date(), + updatedAt: new Date(), + }).returning({ id: biddingCompanies.id }) + + if (biddingCompanyResult.length === 0) { + throw new Error('업체 추가에 실패했습니다.') + } + + const biddingCompanyId = biddingCompanyResult[0].id + + // 2. company_condition_responses 레코드 생성 (기본값) + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyId, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + + return biddingCompanyId + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create bidding detail vendor:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + // 협력업체 정보 저장 - biddingCompanies와 companyConditionResponses 테이블에 레코드 생성 export async function createQuotationVendor(input: any, userId: string) { try { @@ -326,7 +693,7 @@ export async function createQuotationVendor(input: any, userId: string) { biddingCompanyId: biddingCompanyId, paymentTermsResponse: input.paymentTermsResponse || '', taxConditionsResponse: input.taxConditionsResponse || '', - proposedContractDeliveryDate: input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null, + proposedContractDeliveryDate: input.proposedContractDeliveryDate || null, priceAdjustmentResponse: input.priceAdjustmentResponse || false, incotermsResponse: input.incotermsResponse || '', proposedShippingPort: input.proposedShippingPort || '', @@ -342,6 +709,10 @@ export async function createQuotationVendor(input: any, userId: string) { return biddingCompanyId }) + // 캐시 무효화 + revalidateTag(`bidding-${input.biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/[id]`) return { success: true, @@ -390,7 +761,7 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri if (input.paymentTermsResponse !== undefined) conditionsUpdateData.paymentTermsResponse = input.paymentTermsResponse if (input.taxConditionsResponse !== undefined) conditionsUpdateData.taxConditionsResponse = input.taxConditionsResponse if (input.incotermsResponse !== undefined) conditionsUpdateData.incotermsResponse = input.incotermsResponse - if (input.proposedContractDeliveryDate !== undefined) conditionsUpdateData.proposedContractDeliveryDate = input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null + if (input.proposedContractDeliveryDate !== undefined) conditionsUpdateData.proposedContractDeliveryDate = input.proposedContractDeliveryDate || null if (input.proposedShippingPort !== undefined) conditionsUpdateData.proposedShippingPort = input.proposedShippingPort if (input.proposedDestinationPort !== undefined) conditionsUpdateData.proposedDestinationPort = input.proposedDestinationPort if (input.priceAdjustmentResponse !== undefined) conditionsUpdateData.priceAdjustmentResponse = input.priceAdjustmentResponse @@ -406,6 +777,9 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri return true }) + // 캐시 무효화 (모든 입찰 관련 데이터 무효화) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/[id]`) return { success: true, @@ -474,8 +848,12 @@ export async function selectWinner(biddingId: number, vendorId: number, awardRat updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) - }) + }) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '낙찰 처리가 완료되었습니다.' } } catch (error) { @@ -487,6 +865,34 @@ export async function selectWinner(biddingId: number, vendorId: number, awardRat // 유찰 처리 export async function markAsDisposal(biddingId: number, userId: string) { try { + // 입찰 정보 조회 + const biddingInfo = await db + .select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (biddingInfo.length === 0) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + const bidding = biddingInfo[0] + + // 입찰 참여 업체들 조회 + const participantCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + + // 입찰 상태를 유찰로 변경 await db .update(biddings) .set({ @@ -495,85 +901,256 @@ export async function markAsDisposal(biddingId: number, userId: string) { }) .where(eq(biddings.id, biddingId)) + // 참여 업체들에게 유찰 안내 메일 발송 + for (const company of participantCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'bidding-disposal', + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + processedDate: new Date().toLocaleDateString('ko-KR'), + managerName: bidding.managerName, + managerEmail: bidding.managerEmail, + managerPhone: bidding.managerPhone, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send disposal email to ${company.contactEmail}:`, emailError) + } + } + } + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '유찰 처리가 완료되었습니다.' } + + return { + success: true, + message: `유찰 처리가 완료되었습니다. ${participantCompanies.length}개 업체에 안내 메일을 발송했습니다.` + } } catch (error) { console.error('Failed to mark as disposal:', error) return { success: false, error: '유찰 처리에 실패했습니다.' } } } -// 입찰 등록 (상태 변경) +// 입찰 등록 (사전견적에서 선정된 업체들에게 본입찰 초대 발송) export async function registerBidding(biddingId: number, userId: string) { try { - await db - .update(biddings) - .set({ - status: 'bidding_opened', - updatedAt: new Date() + // 사전견적에서 선정된 업체들 조회 + const selectedCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) + )) + + // 입찰 정보 조회 + const biddingInfo = await db + .select() + .from(biddings) .where(eq(biddings.id, biddingId)) - //todo 입찰 등록하면 bidding_companies invitationStatus를 sent로 변경! - await db - .update(biddingCompanies) + .limit(1) + + if (biddingInfo.length === 0) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + const bidding = biddingInfo[0] + + await db.transaction(async (tx) => { + // 1. 입찰 상태를 오픈으로 변경 + await tx + .update(biddings) .set({ - invitationStatus: 'sent', + status: 'bidding_opened', updatedAt: new Date() }) - .where(eq(biddingCompanies.biddingId, biddingId)) + .where(eq(biddings.id, biddingId)) + + // 2. 선정된 업체들의 입찰 초대 여부를 true로 변경하고 초대 상태 업데이트 + for (const company of selectedCompanies) { + await tx + .update(biddingCompanies) + .set({ + isBiddingInvited: true, + invitationStatus: 'sent', + updatedAt: new Date() + }) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, company.companyId) + )) + } + }) + + // 3. 선정된 업체들에게 본입찰 초대 메일 발송 + for (const company of selectedCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'bidding-invitation', // 새로운 본입찰 초대 템플릿 필요 + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + submissionStartDate: bidding.submissionStartDate, + submissionEndDate: bidding.submissionEndDate, + biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, + managerName: bidding.managerName, + managerEmail: bidding.managerEmail, + managerPhone: bidding.managerPhone, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) + } + } + } + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '입찰이 성공적으로 등록되었습니다.' } + + return { + success: true, + message: `입찰이 성공적으로 등록되었습니다. ${selectedCompanies.length}개 업체에 초대 메일을 발송했습니다.` + } } catch (error) { console.error('Failed to register bidding:', error) return { success: false, error: '입찰 등록에 실패했습니다.' } } } -// 재입찰 생성 -export async function createRebidding(originalBiddingId: number, userId: string) { +// 재입찰 생성 (기존 입찰의 revision 업데이트 + 메일 발송) +export async function createRebidding(biddingId: number, userId: string) { try { - // 원본 입찰 정보 조회 - const originalBidding = await db + // 기존 입찰 정보 조회 + const bidding = await db .select() .from(biddings) - .where(eq(biddings.id, originalBiddingId)) + .where(eq(biddings.id, biddingId)) .limit(1) - if (originalBidding.length === 0) { - return { success: false, error: '원본 입찰을 찾을 수 없습니다.' } + if (bidding.length === 0) { + return { success: false, error: '입찰을 찾을 수 없습니다.' } } - const original = originalBidding[0] + const originalBidding = bidding[0] - // 재입찰용 데이터 준비 - const rebiddingData = { - ...original, - id: undefined, - biddingNumber: `${original.biddingNumber}-R${(original.revision || 0) + 1}`, - revision: (original.revision || 0) + 1, - status: 'bidding_generated' as const, - createdAt: new Date(), - updatedAt: new Date() + // 기존 입찰 참여 업체들 조회 + const participantCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + + // 기존 입찰의 revision 증가 및 상태 변경 + const updatedBidding = await db + .update(biddings) + .set({ + revision: (originalBidding.revision || 0) + 1, + status: 'bidding_opened', // 재입찰 시 다시 오픈 상태로 + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + .returning({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision + }) + + if (updatedBidding.length === 0) { + return { success: false, error: '재입찰 업데이트에 실패했습니다.' } } - // 새로운 입찰 생성 - const [newBidding] = await db - .insert(biddings) - .values(rebiddingData) - .returning({ id: biddings.id, biddingNumber: biddings.biddingNumber }) + // 참여 업체들의 상태를 대기로 변경 + await db + .update(biddingCompanies) + .set({ + isBiddingParticipated: null, // 대기 상태로 변경 + invitationStatus: 'sent', + updatedAt: new Date() + }) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + + // 재입찰 안내 메일 발송 + for (const company of participantCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'rebidding-invitation', + context: { + companyName: company.companyName, + biddingNumber: updatedBidding[0].biddingNumber, + title: originalBidding.title, + projectName: originalBidding.projectName, + itemName: originalBidding.itemName, + biddingType: originalBidding.biddingType, + revision: updatedBidding[0].revision || 1, + submissionStartDate: originalBidding.submissionStartDate, + submissionEndDate: originalBidding.submissionEndDate, + biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, + managerName: originalBidding.managerName, + managerEmail: originalBidding.managerEmail, + managerPhone: originalBidding.managerPhone, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send rebidding email to ${company.contactEmail}:`, emailError) + } + } + } + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath('/evcp/bid') - revalidatePath(`/evcp/bid/${newBidding.id}`) + revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, - message: '재입찰이 성공적으로 생성되었습니다.', - data: newBidding + message: `재입찰이 성공적으로 처리되었습니다. ${participantCompanies.length}개 업체에 안내 메일을 발송했습니다.` } } catch (error) { console.error('Failed to create rebidding:', error) - return { success: false, error: '재입찰 생성에 실패했습니다.' } + return { success: false, error: '재입찰 처리에 실패했습니다.' } } } @@ -603,6 +1180,9 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } }) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' } } catch (error) { @@ -611,6 +1191,257 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } } +// 낙찰용 문서 업로드 +export async function uploadAwardDocument(biddingId: number, file: File, userId: string) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/award`, + userId: userId + }) + + if (saveResult.success && saveResult.filePath) { + // biddingDocuments 테이블에 저장 + const [document] = await db.insert(biddingDocuments).values({ + biddingId, + fileName: saveResult.fileName || file.name, + originalFileName: file.name, + filePath: saveResult.filePath, + fileSize: file.size, + documentType: 'award', + title: '낙찰 관련 문서', + description: '낙찰 관련 첨부파일', + uploadedBy: userId, + uploadedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }).returning() + + return { + success: true, + message: '파일이 성공적으로 업로드되었습니다.', + document + } + } else { + return { + success: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + } + } + } catch (error) { + console.error('Failed to upload award document:', error) + return { + success: false, + error: '파일 업로드에 실패했습니다.' + } + } +} + +// 낙찰용 문서 목록 조회 +export async function getAwardDocuments(biddingId: number) { + try { + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other') + )) + .orderBy(desc(biddingDocuments.uploadedAt)) + + return documents + } catch (error) { + console.error('Failed to get award documents:', error) + return [] + } +} + +// 낙찰용 문서 다운로드 +export async function getAwardDocumentForDownload(documentId: number, biddingId: number) { + try { + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other') + )) + .limit(1) + + if (documents.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + return { + success: true, + document: documents[0] + } + } catch (error) { + console.error('Failed to get award document for download:', error) + return { + success: false, + error: '문서 다운로드 준비에 실패했습니다.' + } + } +} + +// 낙찰용 문서 삭제 +export async function deleteAwardDocument(documentId: number, biddingId: number, userId: string) { + try { + // 문서 정보 조회 + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other'), + eq(biddingDocuments.uploadedBy, userId) + )) + .limit(1) + + if (documents.length === 0) { + return { + success: false, + error: '삭제할 수 있는 문서가 없습니다.' + } + } + + // DB에서 삭제 + await db + .delete(biddingDocuments) + .where(eq(biddingDocuments.id, documentId)) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + + return { + success: true, + message: '문서가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete award document:', error) + return { + success: false, + error: '문서 삭제에 실패했습니다.' + } + } +} + +// 낙찰 처리 (발주비율과 함께) +export async function awardBidding(biddingId: number, selectionReason: string, userId: string) { + try { + // 낙찰된 업체들 조회 (isWinner가 true인 업체들) + const awardedCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio + }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + )) + + if (awardedCompanies.length === 0) { + return { success: false, error: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.' } + } + + // 최종입찰가 계산 (낙찰된 업체의 견적금액 * 발주비율의 합) + let finalBidPrice = 0 + for (const company of awardedCompanies) { + const quoteAmount = parseFloat(company.finalQuoteAmount?.toString() || '0') + const ratio = parseFloat(company.awardRatio?.toString() || '0') / 100 + finalBidPrice += quoteAmount * ratio + } + + await db.transaction(async (tx) => { + // 1. 입찰 상태를 낙찰로 변경하고 최종입찰가 업데이트 + await tx + .update(biddings) + .set({ + status: 'vendor_selected', + finalBidPrice: finalBidPrice.toString(), + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + // 2. 선정 사유 저장 (첫 번째 낙찰 업체 기준으로 저장) + const firstAwardedCompany = awardedCompanies[0] + await tx + .insert(vendorSelectionResults) + .values({ + biddingId, + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + .onConflictDoUpdate({ + target: [vendorSelectionResults.biddingId], + set: { + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + updatedAt: new Date() + } + }) + + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + + return { + success: true, + message: `낙찰 처리가 완료되었습니다. 최종입찰가: ${finalBidPrice.toLocaleString()}원` + } + } catch (error) { + console.error('Failed to award bidding:', error) + return { success: false, error: '낙찰 처리에 실패했습니다.' } + } +} + +// 낙찰된 업체 정보 조회 +export async function getAwardedCompanies(biddingId: number) { + try { + 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') + })) + } catch (error) { + console.error('Failed to get awarded companies:', error) + return [] + } +} + // PR 품목 정보 업데이트 export async function updatePrItem(prItemId: number, input: Partial, userId: string) { try { @@ -622,7 +1453,12 @@ export async function updatePrItem(prItemId: number, input: Partial 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } - if (existing.length > 0) { - return { success: false, error: '이미 추가된 협력업체입니다.' } + return { + success: true, + message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + } + } catch (error) { + console.error('Failed to update bidding participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 참여상태 업데이트에 실패했습니다.' } + } +} - // 새로운 협력업체 추가 - await db - .insert(biddingCompanies) - .values({ - biddingId, - companyId, - invitationStatus: 'pending', - invitedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }) +// ================================================= +// 품목별 견적 관련 함수들 (본입찰용) +// ================================================= - revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '협력업체가 성공적으로 추가되었습니다.' } +// 품목별 견적 임시 저장 (본입찰용) +export async function saveBiddingDraft( + biddingCompanyId: number, + prItemQuotations: Array<{ + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string + }>, + userId: string +) { + try { + let totalAmount = 0 + + await db.transaction(async (tx) => { + // 품목별 견적 Upsert 방식으로 저장 + for (const item of prItemQuotations) { + // 기존 데이터 확인 + const existingItem = await tx + .select() + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + ) + ) + .limit(1) + + const itemData = { + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: 'KRW', + updatedAt: new Date() + } + + if (existingItem.length > 0) { + // 업데이트 + await tx + .update(companyPrItemBids) + .set(itemData) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + } else { + // 새로 생성 + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + isPreQuote: false, // 본입찰 데이터 + createdAt: new Date(), + ...itemData + }) + } + + totalAmount += item.bidAmount + } + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingCompanyId}`) + revalidateTag('quotation-vendors') + + return { + success: true, + message: '품목별 견적이 임시 저장되었습니다.', + totalAmount + } } catch (error) { - console.error('Failed to add vendor to bidding:', error) - return { success: false, error: '협력업체 추가에 실패했습니다.' } + console.error('Failed to save bidding draft:', error) + return { + success: false, + error: error instanceof Error ? error.message : '임시 저장에 실패했습니다.' + } } } @@ -671,6 +1595,43 @@ export async function addVendorToBidding(biddingId: number, companyId: number, u // 협력업체 페이지용 함수들 (Partners) // ================================================= +// 협력업체용 입찰 참여여부 업데이트 +export async function updatePartnerBiddingParticipation( + biddingCompanyId: number, + participated: boolean, + userId: string +) { + try { + const result = await db.update(biddingCompanies) + .set({ + isBiddingParticipated: participated, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag(`partners-bidding-${biddingId}`) + revalidatePath(`/partners/bid/${biddingId}`) + } + + return { + success: true, + message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + } + } catch (error) { + console.error('Failed to update partner bidding participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 참여상태 업데이트에 실패했습니다.' + } + } +} + // 협력업체용 입찰 목록 조회 (bidding_companies 기준) export interface PartnersBiddingListItem { // bidding_companies 정보 @@ -683,6 +1644,7 @@ export interface PartnersBiddingListItem { isWinner: boolean | null isAttendingMeeting: boolean | null isPreQuoteSelected: boolean | null + isBiddingInvited: boolean | null notes: string | null createdAt: Date updatedAt: Date @@ -726,6 +1688,7 @@ export async function getBiddingListForPartners(companyId: number): Promise ({ ...item, - respondedAt: item.respondedAt ? item.respondedAt.toISOString() : null, + respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null, finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환 + finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null, responseDeadline: item.submissionStartDate ? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전 : null, @@ -785,6 +1749,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: .select({ // 입찰 기본 정보 id: biddings.id, + biddingId: biddings.id, // partners-bidding-detail.tsx에서 필요한 필드 biddingNumber: biddings.biddingNumber, revision: biddings.revision, projectName: biddings.projectName, @@ -825,6 +1790,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isBiddingParticipated: biddingCompanies.isBiddingParticipated, // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, taxConditionsResponse: companyConditionResponses.taxConditionsResponse, @@ -869,6 +1835,13 @@ export async function submitPartnerResponse( sparePartResponse?: string additionalProposals?: string finalQuoteAmount?: number + prItemQuotations?: Array<{ + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string + }> priceAdjustmentForm?: { itemName?: string adjustmentReflectionPoint?: string @@ -891,91 +1864,98 @@ export async function submitPartnerResponse( ) { try { const result = await db.transaction(async (tx) => { - // 1. company_condition_responses 테이블에 응답 저장/업데이트 - const responseData = { - paymentTermsResponse: response.paymentTermsResponse, - taxConditionsResponse: response.taxConditionsResponse, - incotermsResponse: response.incotermsResponse, - proposedContractDeliveryDate: response.proposedContractDeliveryDate ? response.proposedContractDeliveryDate : null, // Date 대신 string 사용 - proposedShippingPort: response.proposedShippingPort, - proposedDestinationPort: response.proposedDestinationPort, - priceAdjustmentResponse: response.priceAdjustmentResponse, - isInitialResponse: response.isInitialResponse, - sparePartResponse: response.sparePartResponse, - additionalProposals: response.additionalProposals, - submittedAt: new Date(), - updatedAt: new Date(), - } - - // 기존 응답이 있는지 확인 - const existingResponse = await tx - .select() - .from(companyConditionResponses) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - .limit(1) + // 0. 품목별 견적 정보 최종 저장 (본입찰 제출) - Upsert 방식 + if (response.prItemQuotations && response.prItemQuotations.length > 0) { + for (const item of response.prItemQuotations) { + // 기존 데이터 확인 + const existingItem = await tx + .select() + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + ) + ) + .limit(1) - let companyConditionResponseId: number + const itemData = { + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + submittedAt: new Date(), + updatedAt: new Date() + } - if (existingResponse.length > 0) { - // 업데이트 - await tx - .update(companyConditionResponses) - .set(responseData) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - - companyConditionResponseId = existingResponse[0].id - } else { - // 새로 생성 - const [newResponse] = await tx - .insert(companyConditionResponses) - .values({ - biddingCompanyId, - ...responseData, - }) - .returning({ id: companyConditionResponses.id }) - - companyConditionResponseId = newResponse.id + if (existingItem.length > 0) { + // 업데이트 + await tx + .update(companyPrItemBids) + .set(itemData) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + } else { + // 새로 생성 + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + isPreQuote: false, // 본입찰 데이터 + createdAt: new Date(), + ...itemData + }) + } + } } - // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) - if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { - const priceAdjustmentData = { - companyConditionResponsesId: companyConditionResponseId, - itemName: response.priceAdjustmentForm.itemName, - adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: response.priceAdjustmentForm.referenceDate ? new Date(response.priceAdjustmentForm.referenceDate) : null, - comparisonDate: response.priceAdjustmentForm.comparisonDate ? new Date(response.priceAdjustmentForm.comparisonDate) : null, - adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio, - notes: response.priceAdjustmentForm.notes, - adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: response.priceAdjustmentForm.contractorWriter, - adjustmentDate: response.priceAdjustmentForm.adjustmentDate ? new Date(response.priceAdjustmentForm.adjustmentDate) : null, - nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, - } - // 기존 연동제 정보가 있는지 확인 - const existingPriceAdjustment = await tx - .select() - .from(priceAdjustmentForms) - .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) - .limit(1) - if (existingPriceAdjustment.length > 0) { - // 업데이트 - await tx - .update(priceAdjustmentForms) - .set(priceAdjustmentData) - .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) - } else { - // 새로 생성 - await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) - } - } + // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + // if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { + // const priceAdjustmentData = { + // companyConditionResponsesId: companyConditionResponseId, + // itemName: response.priceAdjustmentForm.itemName, + // adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint, + // majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial, + // adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula, + // rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex, + // referenceDate: response.priceAdjustmentForm.referenceDate || null, + // comparisonDate: response.priceAdjustmentForm.comparisonDate || null, + // adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio, + // notes: response.priceAdjustmentForm.notes, + // adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions, + // majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial, + // adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod, + // contractorWriter: response.priceAdjustmentForm.contractorWriter, + // adjustmentDate: response.priceAdjustmentForm.adjustmentDate || null, + // nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, + // } + + // // 기존 연동제 정보가 있는지 확인 + // const existingPriceAdjustment = await tx + // .select() + // .from(priceAdjustmentForms) + // .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + // .limit(1) + + // if (existingPriceAdjustment.length > 0) { + // // 업데이트 + // await tx + // .update(priceAdjustmentForms) + // .set(priceAdjustmentData) + // .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) + // } else { + // // 새로 생성 + // await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) + // } + // } // 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트 const companyUpdateData: any = { @@ -995,9 +1975,38 @@ export async function submitPartnerResponse( .set(companyUpdateData) .where(eq(biddingCompanies.id, biddingCompanyId)) - return true + // biddingId 조회 + const biddingCompanyInfo = await tx + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + const biddingId = biddingCompanyInfo[0]?.biddingId + + // 응찰 제출 시 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만) + if (biddingId && response.finalQuoteAmount !== undefined) { + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'bidding_opened') + )) + } + + return biddingId }) + // 캐시 무효화 + if (result) { + revalidateTag(`bidding-${result}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + } revalidatePath('/partners/bid/[id]') return { success: true, @@ -1090,7 +2099,7 @@ export async function getSpecificationMeetingForPartners(biddingId: number) { data: { ...bidding[0], documents, - meetingDate: specMeeting[0].meetingDate ? specMeeting[0].meetingDate.toISOString().split('T')[0] : null, + meetingDate: specMeeting[0].meetingDate ? (specMeeting[0].meetingDate instanceof Date ? specMeeting[0].meetingDate.toISOString().split('T')[0] : specMeeting[0].meetingDate.toString().split('T')[0]) : null, meetingTime: specMeeting[0].meetingTime, location: specMeeting[0].location, address: specMeeting[0].address, @@ -1186,6 +2195,10 @@ export async function updatePartnerAttendance( // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 } + // 캐시 무효화 + revalidateTag(`bidding-${biddingInfo[0].biddingId}`) + revalidateTag('quotation-vendors') + return { ...biddingInfo[0], companyName, diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx new file mode 100644 index 00000000..3ab883f2 --- /dev/null +++ b/lib/bidding/detail/table/bidding-award-dialog.tsx @@ -0,0 +1,259 @@ +'use client' + +import * as React from 'react' +import { useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import { Trophy, Building2, Calculator } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { getAwardedCompanies, awardBidding } from '@/lib/bidding/detail/service' +import { AwardSimpleFileUpload } from './components/award-simple-file-upload' + +interface BiddingAwardDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface AwardedCompany { + companyId: number + companyName: string | null + finalQuoteAmount: number + awardRatio: number +} + +export function BiddingAwardDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingAwardDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectionReason, setSelectionReason] = React.useState('') + const [awardedCompanies, setAwardedCompanies] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + + // 낙찰된 업체 정보 로드 + React.useEffect(() => { + if (open) { + setIsLoading(true) + getAwardedCompanies(biddingId) + .then(companies => { + setAwardedCompanies(companies) + }) + .catch(error => { + console.error('Failed to load awarded companies:', error) + toast({ + title: '오류', + description: '낙찰 업체 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [open, biddingId, toast]) + + // 최종입찰가 계산 + const finalBidPrice = React.useMemo(() => { + return awardedCompanies.reduce((sum, company) => { + return sum + (company.finalQuoteAmount * company.awardRatio / 100) + }, 0) + }, [awardedCompanies]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!selectionReason.trim()) { + toast({ + title: '유효성 오류', + description: '낙찰 사유를 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (awardedCompanies.length === 0) { + toast({ + title: '유효성 오류', + description: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await awardBidding(biddingId, selectionReason, 'current-user') + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + // 폼 초기화 + setSelectionReason('') + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + + return ( + + + + + + 낙찰 처리 + + + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + + + +
+
+ {/* 낙찰 업체 정보 */} + + + + + 낙찰 업체 정보 + + + + {isLoading ? ( +
+
+

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

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

낙찰된 업체가 없습니다

+

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

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