From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/pre-quote/service.ts | 3145 ++++++++++---------- .../table/bidding-pre-quote-attachments-dialog.tsx | 224 -- .../pre-quote/table/bidding-pre-quote-content.tsx | 51 - .../table/bidding-pre-quote-invitation-dialog.tsx | 770 ----- .../bidding-pre-quote-item-details-dialog.tsx | 125 - .../table/bidding-pre-quote-selection-dialog.tsx | 157 - .../table/bidding-pre-quote-vendor-columns.tsx | 398 --- .../bidding-pre-quote-vendor-create-dialog.tsx | 311 -- .../table/bidding-pre-quote-vendor-edit-dialog.tsx | 200 -- .../table/bidding-pre-quote-vendor-table.tsx | 257 -- .../bidding-pre-quote-vendor-toolbar-actions.tsx | 130 - 11 files changed, 1618 insertions(+), 4150 deletions(-) delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx (limited to 'lib/bidding/pre-quote') diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 0f284297..ea92f294 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -1,1528 +1,1619 @@ -'use server' - -import db from '@/db/db' -import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' -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, 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 } from '@/lib/file-stroage' - -// userId를 user.name으로 변환하는 유틸리티 함수 -async function getUserNameById(userId: string): Promise { - try { - const user = await db - .select({ name: users.name }) - .from(users) - .where(eq(users.id, parseInt(userId))) - .limit(1) - - return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환 - } catch (error) { - console.error('Failed to get user name:', error) - return userId // 에러 시 userId를 그대로 반환 - } -} - -interface CreateBiddingCompanyInput { - biddingId: number - companyId: number - contactPerson?: string - contactEmail?: string - contactPhone?: string - notes?: string -} - -interface UpdateBiddingCompanyInput { - contactPerson?: string - contactEmail?: string - contactPhone?: string - preQuoteAmount?: number - notes?: string - invitationStatus?: 'pending' | 'accepted' | 'declined' - isPreQuoteSelected?: boolean - isAttendingMeeting?: boolean -} - -interface PrItemQuotation { - prItemId: number - bidUnitPrice: number - bidAmount: number - proposedDeliveryDate?: string - technicalSpecification?: string -} - - - -// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성 -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, - companyId: input.companyId, - invitationStatus: 'pending', // 초기 상태: 입찰생성 - invitedAt: new Date(), - contactPerson: input.contactPerson, - contactEmail: input.contactEmail, - contactPhone: input.contactPhone, - notes: input.notes, - }).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, - // 나머지 필드들은 null로 시작 (벤더가 나중에 응답) - }) - - return biddingCompanyId - }) - - return { - success: true, - message: '업체가 성공적으로 추가되었습니다.', - data: { id: result } - } - } catch (error) { - console.error('Failed to create bidding company:', error) - return { - success: false, - error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' - } - } -} - -// 사전견적용 업체 정보 업데이트 -export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) { - try { - const updateData: any = { - updatedAt: new Date() - } - - if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson - if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail - if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone - if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount - if (input.notes !== undefined) updateData.notes = input.notes - if (input.invitationStatus !== undefined) { - updateData.invitationStatus = input.invitationStatus - if (input.invitationStatus !== 'pending') { - updateData.respondedAt = new Date() - } - } - if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected - if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting - - await db.update(biddingCompanies) - .set(updateData) - .where(eq(biddingCompanies.id, id)) - - return { - success: true, - message: '업체 정보가 성공적으로 업데이트되었습니다.', - } - } catch (error) { - console.error('Failed to update bidding company:', error) - return { - success: false, - error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.' - } - } -} - -// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능) -export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) { - try { - // 업체들의 입찰 ID 조회 (캐시 무효화를 위해) - const companies = await db - .select({ biddingId: biddingCompanies.biddingId }) - .from(biddingCompanies) - .where(inArray(biddingCompanies.id, companyIds)) - .limit(1) - - await db.update(biddingCompanies) - .set({ - isPreQuoteSelected: isSelected, - invitationStatus: 'pending', // 초기 상태: 입찰생성 - updatedAt: new Date() - }) - .where(inArray(biddingCompanies.id, companyIds)) - - // 캐시 무효화 - if (companies.length > 0) { - const biddingId = companies[0].biddingId - revalidateTag(`bidding-${biddingId}`) - revalidateTag('bidding-detail') - revalidateTag('quotation-vendors') - revalidateTag('quotation-details') - revalidatePath(`/evcp/bid/${biddingId}`) - } - - const message = isSelected - ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.` - : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.` - - return { - success: true, - message - } - } catch (error) { - console.error('Failed to update pre-quote selection:', error) - return { - success: false, - error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.' - } - } -} - -// 사전견적용 업체 삭제 -export async function deleteBiddingCompany(id: number) { - try { - // 1. 해당 업체의 초대 상태 확인 - const company = await db - .select({ invitationStatus: biddingCompanies.invitationStatus }) - .from(biddingCompanies) - .where(eq(biddingCompanies.id, id)) - .then(rows => rows[0]) - - if (!company) { - return { - success: false, - error: '해당 업체를 찾을 수 없습니다.' - } - } - - // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가 - if (company.invitationStatus !== 'pending') { - return { - success: false, - error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.' - } - } - - await db.transaction(async (tx) => { - // 2. 먼저 관련된 조건 응답들 삭제 - await tx.delete(companyConditionResponses) - .where(eq(companyConditionResponses.biddingCompanyId, id)) - - // 3. biddingCompanies 레코드 삭제 - await tx.delete(biddingCompanies) - .where(eq(biddingCompanies.id, id)) - }) - - return { - success: true, - message: '업체가 성공적으로 삭제되었습니다.' - } - } catch (error) { - console.error('Failed to delete bidding company:', error) - return { - success: false, - error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.' - } - } -} - -// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인) -export async function getBiddingCompanies(biddingId: number) { - try { - const companies = await db - .select({ - // bidding_companies 필드들 - id: biddingCompanies.id, - biddingId: biddingCompanies.biddingId, - companyId: biddingCompanies.companyId, - invitationStatus: biddingCompanies.invitationStatus, - invitedAt: biddingCompanies.invitedAt, - respondedAt: biddingCompanies.respondedAt, - preQuoteAmount: biddingCompanies.preQuoteAmount, - preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, - preQuoteDeadline: biddingCompanies.preQuoteDeadline, - isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, - isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, - isAttendingMeeting: biddingCompanies.isAttendingMeeting, - notes: biddingCompanies.notes, - contactPerson: biddingCompanies.contactPerson, - contactEmail: biddingCompanies.contactEmail, - contactPhone: biddingCompanies.contactPhone, - createdAt: biddingCompanies.createdAt, - updatedAt: biddingCompanies.updatedAt, - - // vendors 테이블에서 업체 정보 - companyName: vendors.vendorName, - companyCode: vendors.vendorCode, - - // company_condition_responses 필드들 - paymentTermsResponse: companyConditionResponses.paymentTermsResponse, - taxConditionsResponse: companyConditionResponses.taxConditionsResponse, - proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, - priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, - isInitialResponse: companyConditionResponses.isInitialResponse, - incotermsResponse: companyConditionResponses.incotermsResponse, - proposedShippingPort: companyConditionResponses.proposedShippingPort, - proposedDestinationPort: companyConditionResponses.proposedDestinationPort, - 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)) - - return { - success: true, - data: companies - } - } catch (error) { - console.error('Failed to get bidding companies:', error) - return { - success: false, - error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.' - } - } -} - -// 선택된 업체들에게 사전견적 초대 발송 -export async function sendPreQuoteInvitations(companyIds: number[], preQuoteDeadline?: Date | string) { - try { - if (companyIds.length === 0) { - return { - success: false, - error: '선택된 업체가 없습니다.' - } - } - - // 선택된 업체들의 정보와 입찰 정보 조회 - const companiesInfo = await db - .select({ - biddingCompanyId: biddingCompanies.id, - companyId: biddingCompanies.companyId, - biddingId: biddingCompanies.biddingId, - companyName: vendors.vendorName, - companyEmail: vendors.email, - // 입찰 정보 - biddingNumber: biddings.biddingNumber, - revision: biddings.revision, - projectName: biddings.projectName, - biddingTitle: biddings.title, - itemName: biddings.itemName, - preQuoteDate: biddings.preQuoteDate, - budget: biddings.budget, - currency: biddings.currency, - managerName: biddings.managerName, - managerEmail: biddings.managerEmail, - managerPhone: biddings.managerPhone, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) - .where(inArray(biddingCompanies.id, companyIds)) - - if (companiesInfo.length === 0) { - return { - success: false, - error: '업체 정보를 찾을 수 없습니다.' - } - } - - await db.transaction(async (tx) => { - // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경 - for (const id of companyIds) { - await tx.update(biddingCompanies) - .set({ - invitationStatus: 'sent', // 사전견적 초대 발송 상태 - invitedAt: new Date(), - preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null, - updatedAt: new Date() - }) - .where(eq(biddingCompanies.id, id)) - } - }) - - // 각 업체별로 이메일 발송 - for (const company of companiesInfo) { - if (company.companyEmail) { - try { - await sendEmail({ - to: company.companyEmail, - template: 'pre-quote-invitation', - context: { - companyName: company.companyName, - biddingNumber: company.biddingNumber, - revision: company.revision, - projectName: company.projectName, - biddingTitle: company.biddingTitle, - itemName: company.itemName, - preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null, - budget: company.budget ? company.budget.toLocaleString() : null, - currency: company.currency, - managerName: company.managerName, - managerEmail: company.managerEmail, - managerPhone: company.managerPhone, - loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`, - currentYear: new Date().getFullYear(), - language: 'ko' - } - }) - } catch (emailError) { - console.error(`Failed to send email to ${company.companyEmail}:`, emailError) - // 이메일 발송 실패해도 전체 프로세스는 계속 진행 - } - } - } - // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) - for (const company of companiesInfo) { - await db.transaction(async (tx) => { - await tx - .update(biddings) - .set({ - status: 'request_for_quotation', - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, company.biddingId), - eq(biddings.status, 'bidding_generated') - )) - }) - } - return { - success: true, - message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.` - } - } catch (error) { - console.error('Failed to send pre-quote invitations:', error) - return { - success: false, - error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.' - } - } -} - -// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계) -export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) { - try { - // 1. 먼저 입찰 기본 정보를 가져옴 - const biddingResult = await db - .select({ - id: biddings.id, - biddingNumber: biddings.biddingNumber, - revision: biddings.revision, - projectName: biddings.projectName, - itemName: biddings.itemName, - title: biddings.title, - description: biddings.description, - content: biddings.content, - contractType: biddings.contractType, - biddingType: biddings.biddingType, - awardCount: biddings.awardCount, - contractStartDate: biddings.contractStartDate, - contractEndDate: biddings.contractEndDate, - preQuoteDate: biddings.preQuoteDate, - biddingRegistrationDate: biddings.biddingRegistrationDate, - submissionStartDate: biddings.submissionStartDate, - submissionEndDate: biddings.submissionEndDate, - evaluationDate: biddings.evaluationDate, - currency: biddings.currency, - budget: biddings.budget, - targetPrice: biddings.targetPrice, - status: biddings.status, - managerName: biddings.managerName, - managerEmail: biddings.managerEmail, - managerPhone: biddings.managerPhone, - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (biddingResult.length === 0) { - return null - } - - const biddingData = biddingResult[0] - - // 2. 해당 업체의 biddingCompanies 정보 조회 - const companyResult = await db - .select({ - biddingCompanyId: biddingCompanies.id, - biddingId: biddingCompanies.biddingId, - invitationStatus: biddingCompanies.invitationStatus, - preQuoteAmount: biddingCompanies.preQuoteAmount, - preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, - preQuoteDeadline: biddingCompanies.preQuoteDeadline, - isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, - isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, - isAttendingMeeting: biddingCompanies.isAttendingMeeting, - // company_condition_responses 정보 - paymentTermsResponse: companyConditionResponses.paymentTermsResponse, - taxConditionsResponse: companyConditionResponses.taxConditionsResponse, - incotermsResponse: companyConditionResponses.incotermsResponse, - proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, - proposedShippingPort: companyConditionResponses.proposedShippingPort, - proposedDestinationPort: companyConditionResponses.proposedDestinationPort, - priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, - sparePartResponse: companyConditionResponses.sparePartResponse, - isInitialResponse: companyConditionResponses.isInitialResponse, - additionalProposals: companyConditionResponses.additionalProposals, - }) - .from(biddingCompanies) - .leftJoin( - companyConditionResponses, - eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) - ) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.companyId, companyId) - ) - ) - .limit(1) - - // 3. 결과 조합 - if (companyResult.length === 0) { - // 아직 초대되지 않은 상태 - return { - ...biddingData, - biddingCompanyId: null, - biddingId: biddingData.id, - invitationStatus: null, - preQuoteAmount: null, - preQuoteSubmittedAt: null, - preQuoteDeadline: null, - isPreQuoteSelected: false, - isPreQuoteParticipated: null, - isAttendingMeeting: null, - paymentTermsResponse: null, - taxConditionsResponse: null, - incotermsResponse: null, - proposedContractDeliveryDate: null, - proposedShippingPort: null, - proposedDestinationPort: null, - priceAdjustmentResponse: null, - sparePartResponse: null, - isInitialResponse: null, - additionalProposals: null, - } - } - - const companyData = companyResult[0] - - return { - ...biddingData, - ...companyData, - biddingId: biddingData.id, // bidding ID 보장 - } - } catch (error) { - console.error('Failed to get bidding companies for partners:', error) - throw error - } -} - -// Partners에서 사전견적 응답 제출 -export async function submitPreQuoteResponse( - biddingCompanyId: number, - responseData: { - preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional - prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가 - paymentTermsResponse?: string - taxConditionsResponse?: string - incotermsResponse?: string - proposedContractDeliveryDate?: string - proposedShippingPort?: string - proposedDestinationPort?: string - priceAdjustmentResponse?: boolean - isInitialResponse?: boolean - sparePartResponse?: string - additionalProposals?: string - priceAdjustmentForm?: any - }, - userId: string -) { - try { - let finalAmount = responseData.preQuoteAmount || 0 - - await db.transaction(async (tx) => { - // 1. 품목별 견적 정보 최종 저장 (사전견적 제출) - if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { - // 기존 사전견적 품목 삭제 후 새로 생성 - await tx.delete(companyPrItemBids) - .where( - and( - eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), - eq(companyPrItemBids.isPreQuote, true) - ) - ) - - // 품목별 견적 최종 저장 - for (const item of responseData.prItemQuotations) { - await tx.insert(companyPrItemBids) - .values({ - biddingCompanyId, - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice.toString(), - bidAmount: item.bidAmount.toString(), - proposedDeliveryDate: item.proposedDeliveryDate || null, - technicalSpecification: item.technicalSpecification || null, - currency: 'KRW', - isPreQuote: true, - submittedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }) - } - - // 총 금액 다시 계산 - finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) - } - - // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경) - await tx.update(biddingCompanies) - .set({ - preQuoteAmount: finalAmount.toString(), - preQuoteSubmittedAt: new Date(), - invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경 - updatedAt: new Date() - }) - .where(eq(biddingCompanies.id, biddingCompanyId)) - - // 3. company_condition_responses 업데이트 - const finalConditionResult = await tx.update(companyConditionResponses) - .set({ - paymentTermsResponse: responseData.paymentTermsResponse, - taxConditionsResponse: responseData.taxConditionsResponse, - incotermsResponse: responseData.incotermsResponse, - proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, - proposedShippingPort: responseData.proposedShippingPort, - proposedDestinationPort: responseData.proposedDestinationPort, - priceAdjustmentResponse: responseData.priceAdjustmentResponse, - isInitialResponse: responseData.isInitialResponse, - sparePartResponse: responseData.sparePartResponse, - additionalProposals: responseData.additionalProposals, - updatedAt: new Date() - }) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - .returning() - - // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) - if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) { - const companyConditionResponseId = finalConditionResult[0].id - - const priceAdjustmentData = { - companyConditionResponsesId: companyConditionResponseId, - itemName: responseData.priceAdjustmentForm.itemName, - adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, - notes: responseData.priceAdjustmentForm.notes, - adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, - nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } as any - - // 기존 연동제 정보가 있는지 확인 - 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) - } - } - - // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만) - // 또한 사전견적 접수일 업데이트 - const biddingCompany = await tx - .select({ biddingId: biddingCompanies.biddingId }) - .from(biddingCompanies) - .where(eq(biddingCompanies.id, biddingCompanyId)) - .limit(1) - - if (biddingCompany.length > 0) { - await tx - .update(biddings) - .set({ - status: 'received_quotation', - preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트 - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, biddingCompany[0].biddingId), - eq(biddings.status, 'request_for_quotation') - )) - } - }) - - return { - success: true, - message: '사전견적이 성공적으로 제출되었습니다.' - } - } catch (error) { - console.error('Failed to submit pre-quote response:', error) - return { - success: false, - error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.' - } - } -} - -// Partners에서 사전견적 참여 의사 결정 (수락/거절) -export async function respondToPreQuoteInvitation( - biddingCompanyId: number, - response: 'accepted' | 'declined' -) { - try { - await db.update(biddingCompanies) - .set({ - invitationStatus: response, // accepted 또는 declined - respondedAt: new Date(), - updatedAt: new Date() - }) - .where(eq(biddingCompanies.id, biddingCompanyId)) - - const message = response === 'accepted' ? - '사전견적 참여를 수락했습니다.' : - '사전견적 참여를 거절했습니다.' - - return { - success: true, - message - } - } catch (error) { - console.error('Failed to respond to pre-quote invitation:', error) - return { - success: false, - error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.' - } - } -} - -// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용) -export async function setPreQuoteParticipation( - biddingCompanyId: number, - isParticipating: boolean -) { - try { - await db.update(biddingCompanies) - .set({ - isPreQuoteParticipated: isParticipating, - respondedAt: new Date(), - updatedAt: new Date() - }) - .where(eq(biddingCompanies.id, biddingCompanyId)) - - const message = isParticipating ? - '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' : - '사전견적 참여를 거절했습니다.' - - return { - success: true, - message - } - } catch (error) { - console.error('Failed to set pre-quote participation:', error) - return { - success: false, - error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.' - } - } -} - -// PR 아이템 조회 (입찰에 포함된 품목들) -export async function getPrItemsForBidding(biddingId: number) { - try { - const prItems = await db - .select({ - id: prItemsForBidding.id, - itemNumber: prItemsForBidding.itemNumber, - prNumber: prItemsForBidding.prNumber, - itemInfo: prItemsForBidding.itemInfo, - materialDescription: prItemsForBidding.materialDescription, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - totalWeight: prItemsForBidding.totalWeight, - weightUnit: prItemsForBidding.weightUnit, - currency: prItemsForBidding.currency, - requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, - hasSpecDocument: prItemsForBidding.hasSpecDocument - }) - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - - return prItems - } catch (error) { - console.error('Failed to get PR items for bidding:', error) - return [] - } -} - -// SPEC 문서 조회 (PR 아이템에 연결된 문서들) -export async function getSpecDocumentsForPrItem(prItemId: number) { - try { - - const specDocs = await db - .select({ - id: biddingDocuments.id, - fileName: biddingDocuments.fileName, - originalFileName: biddingDocuments.originalFileName, - fileSize: biddingDocuments.fileSize, - filePath: biddingDocuments.filePath, - title: biddingDocuments.title, - description: biddingDocuments.description, - uploadedAt: biddingDocuments.uploadedAt - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.prItemId, prItemId), - eq(biddingDocuments.documentType, 'spec_document') - ) - ) - - return specDocs - } catch (error) { - console.error('Failed to get spec documents for PR item:', error) - return [] - } -} - -// 사전견적 임시저장 -export async function savePreQuoteDraft( - biddingCompanyId: number, - responseData: { - prItemQuotations?: PrItemQuotation[] - paymentTermsResponse?: string - taxConditionsResponse?: string - incotermsResponse?: string - proposedContractDeliveryDate?: string - proposedShippingPort?: string - proposedDestinationPort?: string - priceAdjustmentResponse?: boolean - isInitialResponse?: boolean - sparePartResponse?: string - additionalProposals?: string - priceAdjustmentForm?: any - }, - userId: string -) { - try { - let totalAmount = 0 - - await db.transaction(async (tx) => { - // 품목별 견적 정보 저장 - if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { - // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기) - await tx.delete(companyPrItemBids) - .where( - and( - eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), - eq(companyPrItemBids.isPreQuote, true) - ) - ) - - // 새로운 품목별 견적 저장 - for (const item of responseData.prItemQuotations) { - await tx.insert(companyPrItemBids) - .values({ - biddingCompanyId, - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice.toString(), - bidAmount: item.bidAmount.toString(), - proposedDeliveryDate: item.proposedDeliveryDate || null, - technicalSpecification: item.technicalSpecification || null, - currency: 'KRW', - isPreQuote: true, // 사전견적 표시 - submittedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }) - } - - // 총 금액 계산 - totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) - - // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음) - await tx.update(biddingCompanies) - .set({ - preQuoteAmount: totalAmount.toString(), - updatedAt: new Date() - }) - .where(eq(biddingCompanies.id, biddingCompanyId)) - } - - // company_condition_responses 업데이트 (임시저장) - const conditionResult = await tx.update(companyConditionResponses) - .set({ - paymentTermsResponse: responseData.paymentTermsResponse || null, - taxConditionsResponse: responseData.taxConditionsResponse || null, - incotermsResponse: responseData.incotermsResponse || null, - proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null, - proposedShippingPort: responseData.proposedShippingPort || null, - proposedDestinationPort: responseData.proposedDestinationPort || null, - priceAdjustmentResponse: responseData.priceAdjustmentResponse || null, - isInitialResponse: responseData.isInitialResponse || null, - sparePartResponse: responseData.sparePartResponse || null, - additionalProposals: responseData.additionalProposals || null, - updatedAt: new Date() - }) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - .returning() - - // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) - if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) { - const companyConditionResponseId = conditionResult[0].id - - const priceAdjustmentData = { - companyConditionResponsesId: companyConditionResponseId, - itemName: responseData.priceAdjustmentForm.itemName, - adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, - notes: responseData.priceAdjustmentForm.notes, - adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, - nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } as any - - // 기존 연동제 정보가 있는지 확인 - 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) - } - } - }) - - return { - success: true, - message: '임시저장이 완료되었습니다.', - totalAmount - } - } catch (error) { - console.error('Failed to save pre-quote draft:', error) - return { - success: false, - error: error instanceof Error ? error.message : '임시저장에 실패했습니다.' - } - } -} - -// 견적 문서 업로드 -export async function uploadPreQuoteDocument( - biddingId: number, - companyId: number, - file: File, - userId: string -) { - try { - const userName = await getUserNameById(userId) - // 파일 저장 - const saveResult = await saveFile({ - file, - directory: `bidding/${biddingId}/quotations`, - originalName: file.name, - userId - }) - - if (!saveResult.success) { - return { - success: false, - error: saveResult.error || '파일 저장에 실패했습니다.' - } - } - - // 데이터베이스에 문서 정보 저장 - const result = await db.insert(biddingDocuments) - .values({ - biddingId, - companyId, - documentType: 'other', // 견적서 타입 - fileName: saveResult.fileName!, - originalFileName: file.name, - fileSize: file.size, - mimeType: file.type, - filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로) - title: `견적서 - ${file.name}`, - description: '협력업체 제출 견적서', - isPublic: false, - isRequired: false, - uploadedBy: userName, - uploadedAt: new Date() - }) - .returning() - - return { - success: true, - message: '견적서가 성공적으로 업로드되었습니다.', - documentId: result[0].id - } - } catch (error) { - console.error('Failed to upload pre-quote document:', error) - return { - success: false, - error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.' - } - } -} - -// 업로드된 견적 문서 목록 조회 -export async function getPreQuoteDocuments(biddingId: number, companyId: number) { - try { - const documents = await db - .select({ - id: biddingDocuments.id, - fileName: biddingDocuments.fileName, - originalFileName: biddingDocuments.originalFileName, - fileSize: biddingDocuments.fileSize, - filePath: biddingDocuments.filePath, - title: biddingDocuments.title, - description: biddingDocuments.description, - uploadedAt: biddingDocuments.uploadedAt, - uploadedBy: biddingDocuments.uploadedBy - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.biddingId, biddingId), - eq(biddingDocuments.companyId, companyId), - ) - ) - - return documents - } catch (error) { - console.error('Failed to get pre-quote documents:', error) - return [] - } - } - -// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용) -export async function getSavedPrItemQuotations(biddingCompanyId: number) { - try { - const savedQuotations = await db - .select({ - prItemId: companyPrItemBids.prItemId, - bidUnitPrice: companyPrItemBids.bidUnitPrice, - bidAmount: companyPrItemBids.bidAmount, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, - technicalSpecification: companyPrItemBids.technicalSpecification, - currency: companyPrItemBids.currency - }) - .from(companyPrItemBids) - .where( - and( - eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), - eq(companyPrItemBids.isPreQuote, true) - ) - ) - - // Decimal 타입을 number로 변환 - return savedQuotations.map(item => ({ - prItemId: item.prItemId, - bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), - bidAmount: parseFloat(item.bidAmount || '0'), - proposedDeliveryDate: item.proposedDeliveryDate, - technicalSpecification: item.technicalSpecification, - currency: item.currency - })) - } catch (error) { - console.error('Failed to get saved PR item quotations:', error) - return [] - } - } - -// 견적 문서 정보 조회 (다운로드용) -export async function getPreQuoteDocumentForDownload( - documentId: number, - biddingId: number, - companyId: number -) { - try { - const document = await db - .select({ - fileName: biddingDocuments.fileName, - originalFileName: biddingDocuments.originalFileName, - filePath: biddingDocuments.filePath - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.id, documentId), - eq(biddingDocuments.biddingId, biddingId), - eq(biddingDocuments.companyId, companyId), - eq(biddingDocuments.documentType, 'other') - ) - ) - .limit(1) - - if (document.length === 0) { - return { - success: false, - error: '문서를 찾을 수 없습니다.' - } - } - - return { - success: true, - document: document[0] - } - } catch (error) { - console.error('Failed to get pre-quote document:', error) - return { - success: false, - error: '문서 정보 조회에 실패했습니다.' - } - } -} - -// 견적 문서 삭제 -export async function deletePreQuoteDocument( - documentId: number, - biddingId: number, - companyId: number, - userId: string -) { - try { - // 문서 존재 여부 및 권한 확인 - const document = await db - .select({ - id: biddingDocuments.id, - fileName: biddingDocuments.fileName, - filePath: biddingDocuments.filePath, - uploadedBy: biddingDocuments.uploadedBy - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.id, documentId), - eq(biddingDocuments.biddingId, biddingId), - eq(biddingDocuments.companyId, companyId), - eq(biddingDocuments.documentType, 'other') - ) - ) - .limit(1) - - if (document.length === 0) { - return { - success: false, - error: '문서를 찾을 수 없습니다.' - } - } - - const doc = document[0] - - // 데이터베이스에서 문서 정보 삭제 - await db - .delete(biddingDocuments) - .where(eq(biddingDocuments.id, documentId)) - - return { - success: true, - message: '문서가 성공적으로 삭제되었습니다.' - } - } catch (error) { - console.error('Failed to delete pre-quote document:', error) - return { - success: false, - error: '문서 삭제에 실패했습니다.' - } - } - } - -// 기본계약 발송 (서버 액션) -export async function sendBiddingBasicContracts( - biddingId: number, - vendorData: Array<{ - vendorId: number - vendorName: string - vendorCode?: string - vendorCountry?: string - selectedMainEmail: string - additionalEmails: string[] - customEmails?: Array<{ email: string; name?: string }> - contractRequirements: { - ndaYn: boolean - generalGtcYn: boolean - projectGtcYn: boolean - agreementYn: boolean - } - biddingCompanyId: number - biddingId: number - hasExistingContracts?: boolean - }>, - generatedPdfs: Array<{ - key: string - buffer: number[] - fileName: string - }>, - message?: string -) { - try { - console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) }); - - // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용) - const [currentUser] = await db.select().from(users).limit(1) - - if (!currentUser) { - throw new Error("사용자 정보를 찾을 수 없습니다.") - } - - const results = [] - const savedContracts = [] - - // 트랜잭션 시작 - 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) { - // 기존 계약 확인 (biddingCompanyId 기준) - if (vendor.hasExistingContracts) { - console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`) - continue - } - - // 벤더 정보 조회 - const [vendorInfo] = await tx - .select() - .from(vendors) - .where(eq(vendors.id, vendor.vendorId)) - .limit(1) - - if (!vendorInfo) { - console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`) - continue - } - - // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용) - console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`) - let [biddingCompanyInfo] = await tx - .select() - .from(biddingCompanies) - .where(eq(biddingCompanies.id, vendor.biddingCompanyId)) - .limit(1) - - console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo) - if (!biddingCompanyInfo) { - console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`) - // fallback: biddingId와 vendorId로 찾기 시도 - console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`) - const [fallbackCompanyInfo] = await tx - .select() - .from(biddingCompanies) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.companyId, vendor.vendorId) - )) - .limit(1) - console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo) - if (fallbackCompanyInfo) { - console.log(`Using fallback biddingCompanyInfo`) - biddingCompanyInfo = fallbackCompanyInfo - } else { - console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10)) - continue - } - } - - // 계약 요구사항에 따라 계약서 생성 - const contractTypes: Array<{ type: string; templateName: string }> = [] - if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' }) - if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' }) - 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)) - const pdfData = generatedPdfs.find((pdf: any) => - pdf.key.includes(`${vendor.vendorId}_`) && - pdf.key.includes(`_${contractType.templateName}`) - ) - console.log("pdfData", pdfData, "for contractType", contractType) - if (!pdfData) { - console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`) - continue - } - - // 파일 저장 (rfq-last 방식) - const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf` - const filePath = path.join(contractsDir, fileName); - - await writeFile(filePath, Buffer.from(pdfData.buffer)); - - // 템플릿 정보 조회 (rfq-last 방식) - const [template] = await db - .select() - .from(basicContractTemplates) - .where( - and( - ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`), - eq(basicContractTemplates.status, "ACTIVE") - ) - ) - .limit(1); - - console.log("템플릿", contractType.templateName, template); - - // 기존 계약이 있는지 확인 (rfq-last 방식) - const [existingContract] = await tx - .select() - .from(basicContract) - .where( - and( - eq(basicContract.templateId, template?.id), - eq(basicContract.vendorId, vendor.vendorId), - eq(basicContract.biddingCompanyId, biddingCompanyInfo.id) - ) - ) - .limit(1); - - let contractRecord; - - if (existingContract) { - // 기존 계약이 있으면 업데이트 - [contractRecord] = await tx - .update(basicContract) - .set({ - requestedBy: currentUser.id, - status: "PENDING", // 재발송 상태 - fileName: fileName, - filePath: `/contracts/generated/${fileName}`, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), - updatedAt: new Date(), - }) - .where(eq(basicContract.id, existingContract.id)) - .returning(); - - console.log("기존 계약 업데이트:", contractRecord.id); - } else { - // 새 계약 생성 - [contractRecord] = await tx - .insert(basicContract) - .values({ - templateId: template?.id || null, - vendorId: vendor.vendorId, - biddingCompanyId: biddingCompanyInfo.id, - rfqCompanyId: null, - generalContractId: null, - requestedBy: currentUser.id, - status: 'PENDING', - fileName: fileName, - filePath: `/contracts/generated/${fileName}`, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후 - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - - console.log("새 계약 생성:", contractRecord.id); - } - - results.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - contractId: contractRecord.id, - contractType: contractType.type, - 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, // 업데이트 여부 표시 - // }) - } - - // 이메일 발송 (선택사항) - if (vendor.selectedMainEmail) { - try { - await sendEmail({ - to: vendor.selectedMainEmail, - template: 'basic-contract-notification', - context: { - vendorName: vendor.vendorName, - biddingId: biddingId, - contractCount: contractTypes.length, - deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'), - loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`, - message: message || '', - currentYear: new Date().getFullYear(), - language: 'ko' - } - }) - } catch (emailError) { - console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError) - // 이메일 발송 실패해도 계약 생성은 유지 - } - } - } - - return { - success: true, - message: `${results.length}개의 기본계약이 생성되었습니다.`, - results, - savedContracts, - totalContracts: savedContracts.length, - } - }) - - return result - - } catch (error) { - console.error('기본계약 발송 실패:', error) - throw new Error( - error instanceof Error - ? error.message - : '기본계약 발송 중 오류가 발생했습니다.' - ) - } -} - -// 기존 기본계약 조회 (서버 액션) -export async function getExistingBasicContractsForBidding(biddingId: number) { - try { - // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회 - const existingContracts = await db - .select({ - id: basicContract.id, - vendorId: basicContract.vendorId, - biddingCompanyId: basicContract.biddingCompanyId, - biddingId: biddingCompanies.biddingId, - templateId: basicContract.templateId, - status: basicContract.status, - createdAt: basicContract.createdAt, - }) - .from(basicContract) - .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id)) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - ) - ) - - return { - success: true, - contracts: existingContracts - } - - } catch (error) { - console.error('기존 계약 조회 실패:', error) - return { - success: false, - error: '기존 계약 조회에 실패했습니다.' - } - } -} - -// 선정된 업체들 조회 (서버 액션) -export async function getSelectedVendorsForBidding(biddingId: number) { - try { - const selectedCompanies = await db - .select({ - id: biddingCompanies.id, - companyId: biddingCompanies.companyId, - companyName: vendors.vendorName, - companyCode: vendors.vendorCode, - companyCountry: vendors.country, - contactPerson: biddingCompanies.contactPerson, - contactEmail: biddingCompanies.contactEmail, - biddingId: biddingCompanies.biddingId, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isPreQuoteSelected, true) - )) - - return { - success: true, - vendors: selectedCompanies.map(company => ({ - vendorId: company.companyId, // 실제 vendor ID - vendorName: company.companyName || '', - vendorCode: company.companyCode, - vendorCountry: company.companyCountry || '대한민국', - contactPerson: company.contactPerson, - contactEmail: company.contactEmail, - biddingCompanyId: company.id, // biddingCompany ID - biddingId: company.biddingId, - ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정) - generalGtcYn: true, - projectGtcYn: true, - agreementYn: true - })) - } - } catch (error) { - console.error('선정된 업체 조회 실패:', error) - return { - success: false, - error: '선정된 업체 조회에 실패했습니다.', - vendors: [] - } - } +'use server' + +import db from '@/db/db' +import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' +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, 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 } from '@/lib/file-stroage' + +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환 + } catch (error) { + console.error('Failed to get user name:', error) + return userId // 에러 시 userId를 그대로 반환 + } +} + +interface CreateBiddingCompanyInput { + biddingId: number + companyId: number + contactPerson?: string + contactEmail?: string + contactPhone?: string + notes?: string +} + +interface UpdateBiddingCompanyInput { + contactPerson?: string + contactEmail?: string + contactPhone?: string + preQuoteAmount?: number + notes?: string + invitationStatus?: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted' + isPreQuoteSelected?: boolean + isAttendingMeeting?: boolean +} + +interface PrItemQuotation { + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string +} + + + + // 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성 +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, + companyId: input.companyId, + invitationStatus: 'pending', // 초기 상태: 초대 대기 + invitedAt: new Date(), + contactPerson: input.contactPerson, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + notes: input.notes, + }).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, + // 나머지 필드들은 null로 시작 (벤더가 나중에 응답) + }) + + return biddingCompanyId + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + +// 사전견적용 업체 정보 업데이트 +export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) { + try { + const updateData: any = { + updatedAt: new Date() + } + + if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson + if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail + if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone + if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount + if (input.notes !== undefined) updateData.notes = input.notes + if (input.invitationStatus !== undefined) { + updateData.invitationStatus = input.invitationStatus + if (input.invitationStatus !== 'pending') { + updateData.respondedAt = new Date() + } + } + if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected + if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting + + await db.update(biddingCompanies) + .set(updateData) + .where(eq(biddingCompanies.id, id)) + + return { + success: true, + message: '업체 정보가 성공적으로 업데이트되었습니다.', + } + } catch (error) { + console.error('Failed to update bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.' + } + } +} + +// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능) +export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) { + try { + // 업체들의 입찰 ID 조회 (캐시 무효화를 위해) + const companies = await db + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(inArray(biddingCompanies.id, companyIds)) + .limit(1) + + await db.update(biddingCompanies) + .set({ + isPreQuoteSelected: isSelected, + invitationStatus: 'pending', // 초기 상태: 초대 대기 + updatedAt: new Date() + }) + .where(inArray(biddingCompanies.id, companyIds)) + + // 캐시 무효화 + if (companies.length > 0) { + const biddingId = companies[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('bidding-detail') + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + const message = isSelected + ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.` + : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.` + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to update pre-quote selection:', error) + return { + success: false, + error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.' + } + } +} + +// 사전견적용 업체 삭제 +export async function deleteBiddingCompany(id: number) { + try { + // 1. 해당 업체의 초대 상태 확인 + const company = await db + .select({ invitationStatus: biddingCompanies.invitationStatus }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, id)) + .then(rows => rows[0]) + + if (!company) { + return { + success: false, + error: '해당 업체를 찾을 수 없습니다.' + } + } + + // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가 + if (company.invitationStatus !== 'pending') { + return { + success: false, + error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.' + } + } + + await db.transaction(async (tx) => { + // 2. 먼저 관련된 조건 응답들 삭제 + await tx.delete(companyConditionResponses) + .where(eq(companyConditionResponses.biddingCompanyId, id)) + + // 3. biddingCompanies 레코드 삭제 + await tx.delete(biddingCompanies) + .where(eq(biddingCompanies.id, id)) + }) + + return { + success: true, + message: '업체가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete bidding company:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.' + } + } +} + +// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인) +export async function getBiddingCompanies(biddingId: number) { + try { + const companies = await db + .select({ + // bidding_companies 필드들 + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + companyId: biddingCompanies.companyId, + invitationStatus: biddingCompanies.invitationStatus, + invitedAt: biddingCompanies.invitedAt, + respondedAt: biddingCompanies.respondedAt, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + preQuoteDeadline: biddingCompanies.preQuoteDeadline, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + notes: biddingCompanies.notes, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + contactPhone: biddingCompanies.contactPhone, + createdAt: biddingCompanies.createdAt, + updatedAt: biddingCompanies.updatedAt, + + // vendors 테이블에서 업체 정보 + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + companyEmail: vendors.email, // 벤더의 기본 이메일 + + // company_condition_responses 필드들 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + 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)) + + // 디버깅: 서버에서 가져온 데이터 확인 + console.log('=== getBiddingCompanies Server Log ===') + console.log('Total companies:', companies.length) + if (companies.length > 0) { + console.log('First company:', { + companyName: companies[0].companyName, + companyEmail: companies[0].companyEmail, + companyCode: companies[0].companyCode, + companyId: companies[0].companyId + }) + } + console.log('======================================') + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.' + } + } +} + +// 선택된 업체들에게 사전견적 초대 발송 +interface CompanyWithContacts { + id: number + companyId: number + companyName: string + selectedMainEmail: string + additionalEmails: string[] +} + +export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) { + try { + console.log('=== sendPreQuoteInvitations called ==='); + console.log('companiesData:', JSON.stringify(companiesData, null, 2)); + + if (companiesData.length === 0) { + return { + success: false, + error: '선택된 업체가 없습니다.' + } + } + + const companyIds = companiesData.map(c => c.id); + console.log('companyIds:', companyIds); + + // 선택된 업체들의 정보와 입찰 정보 조회 + const companiesInfo = await db + .select({ + biddingCompanyId: biddingCompanies.id, + companyId: biddingCompanies.companyId, + biddingId: biddingCompanies.biddingId, + companyName: vendors.vendorName, + companyEmail: vendors.email, + // 입찰 정보 + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + biddingTitle: biddings.title, + itemName: biddings.itemName, + preQuoteDate: biddings.preQuoteDate, + budget: biddings.budget, + currency: biddings.currency, + bidPicName: biddings.bidPicName, + supplyPicName: biddings.supplyPicName, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .where(inArray(biddingCompanies.id, companyIds)) + + console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2)); + + if (companiesInfo.length === 0) { + return { + success: false, + error: '업체 정보를 찾을 수 없습니다.' + } + } + + // 모든 필드가 null이 아닌지 확인하고 안전하게 변환 + const safeCompaniesInfo = companiesInfo.map(company => ({ + ...company, + companyName: company.companyName ?? '', + companyEmail: company.companyEmail ?? '', + biddingNumber: company.biddingNumber ?? '', + revision: company.revision ?? '', + projectName: company.projectName ?? '', + biddingTitle: company.biddingTitle ?? '', + itemName: company.itemName ?? '', + preQuoteDate: company.preQuoteDate ?? null, + budget: company.budget ?? null, + currency: company.currency ?? '', + bidPicName: company.bidPicName ?? '', + supplyPicName: company.supplyPicName ?? '', + })); + + console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2)); + + await db.transaction(async (tx) => { + // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경 + for (const id of companyIds) { + await tx.update(biddingCompanies) + .set({ + invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태 + invitedAt: new Date(), + preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, id)) + } + }) + + // 각 업체별로 이메일 발송 (담당자 정보 포함) + console.log('=== Starting email sending ==='); + for (const company of safeCompaniesInfo) { + console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`); + + const companyData = companiesData.find(c => c.id === company.biddingCompanyId); + if (!companyData) { + console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`); + continue; + } + + console.log('companyData found:', JSON.stringify(companyData, null, 2)); + + const mainEmail = companyData.selectedMainEmail || ''; + const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : []; + + console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`); + + if (mainEmail) { + try { + console.log('Preparing to send email...'); + + const emailContext = { + companyName: company.companyName, + biddingNumber: company.biddingNumber, + revision: company.revision, + projectName: company.projectName, + biddingTitle: company.biddingTitle, + itemName: company.itemName, + preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : '', + budget: company.budget ? String(company.budget) : '', + currency: company.currency, + bidPicName: company.bidPicName, + supplyPicName: company.supplyPicName, + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`, + currentYear: new Date().getFullYear(), + language: 'ko' + }; + + console.log('Email context prepared:', JSON.stringify(emailContext, null, 2)); + + await sendEmail({ + to: mainEmail, + cc: ccEmails.length > 0 ? ccEmails : undefined, + template: 'pre-quote-invitation', + context: emailContext + }) + + console.log(`Email sent successfully to ${mainEmail}`); + } catch (emailError) { + console.error(`Failed to send email to ${mainEmail}:`, emailError) + // 이메일 발송 실패해도 전체 프로세스는 계속 진행 + } + } + } + // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) + for (const company of companiesInfo) { + await db.transaction(async (tx) => { + await tx + .update(biddings) + .set({ + status: 'request_for_quotation', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, company.biddingId), + eq(biddings.status, 'bidding_generated') + )) + }) + } + return { + success: true, + message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.` + } + } catch (error) { + console.error('Failed to send pre-quote invitations:', error) + return { + success: false, + error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.' + } + } +} + +// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계) +export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) { + try { + // 1. 먼저 입찰 기본 정보를 가져옴 + const biddingResult = await db + .select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + itemName: biddings.itemName, + title: biddings.title, + description: biddings.description, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + awardCount: biddings.awardCount, + contractStartDate: biddings.contractStartDate, + contractEndDate: biddings.contractEndDate, + preQuoteDate: biddings.preQuoteDate, + biddingRegistrationDate: biddings.biddingRegistrationDate, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + evaluationDate: biddings.evaluationDate, + currency: biddings.currency, + budget: biddings.budget, + targetPrice: biddings.targetPrice, + status: biddings.status, + bidPicName: biddings.bidPicName, + supplyPicName: biddings.supplyPicName, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (biddingResult.length === 0) { + return null + } + + const biddingData = biddingResult[0] + + // 2. 해당 업체의 biddingCompanies 정보 조회 + const companyResult = await db + .select({ + biddingCompanyId: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + invitationStatus: biddingCompanies.invitationStatus, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + preQuoteDeadline: biddingCompanies.preQuoteDeadline, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + // company_condition_responses 정보 + paymentTermsResponse: companyConditionResponses.paymentTermsResponse, + taxConditionsResponse: companyConditionResponses.taxConditionsResponse, + incotermsResponse: companyConditionResponses.incotermsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + sparePartResponse: companyConditionResponses.sparePartResponse, + isInitialResponse: companyConditionResponses.isInitialResponse, + additionalProposals: companyConditionResponses.additionalProposals, + }) + .from(biddingCompanies) + .leftJoin( + companyConditionResponses, + eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId) + ) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + // 3. 결과 조합 + if (companyResult.length === 0) { + // 아직 초대되지 않은 상태 + return { + ...biddingData, + biddingCompanyId: null, + biddingId: biddingData.id, + invitationStatus: null, + preQuoteAmount: null, + preQuoteSubmittedAt: null, + preQuoteDeadline: null, + isPreQuoteSelected: false, + isPreQuoteParticipated: null, + isAttendingMeeting: null, + paymentTermsResponse: null, + taxConditionsResponse: null, + incotermsResponse: null, + proposedContractDeliveryDate: null, + proposedShippingPort: null, + proposedDestinationPort: null, + priceAdjustmentResponse: null, + sparePartResponse: null, + isInitialResponse: null, + additionalProposals: null, + } + } + + const companyData = companyResult[0] + + return { + ...biddingData, + ...companyData, + biddingId: biddingData.id, // bidding ID 보장 + } + } catch (error) { + console.error('Failed to get bidding companies for partners:', error) + throw error + } +} + +// Partners에서 사전견적 응답 제출 +export async function submitPreQuoteResponse( + biddingCompanyId: number, + responseData: { + preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional + prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가 + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let finalAmount = responseData.preQuoteAmount || 0 + + await db.transaction(async (tx) => { + // 1. 품목별 견적 정보 최종 저장 (사전견적 제출) + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 후 새로 생성 + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 품목별 견적 최종 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 다시 계산 + finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + } + + // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: finalAmount.toString(), + preQuoteSubmittedAt: new Date(), + invitationStatus: 'pre_quote_submitted', // 사전견적제출완료 상태로 변경 + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + // 3. company_condition_responses 업데이트 + const finalConditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + isInitialResponse: responseData.isInitialResponse, + sparePartResponse: responseData.sparePartResponse, + additionalProposals: responseData.additionalProposals, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) { + const companyConditionResponseId = finalConditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } as any + + // 기존 연동제 정보가 있는지 확인 + 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) + } + } + + // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만) + // 또한 사전견적 접수일 업데이트 + const biddingCompany = await tx + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + if (biddingCompany.length > 0) { + await tx + .update(biddings) + .set({ + status: 'received_quotation', + preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트 + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingCompany[0].biddingId), + eq(biddings.status, 'request_for_quotation') + )) + } + }) + + return { + success: true, + message: '사전견적이 성공적으로 제출되었습니다.' + } + } catch (error) { + console.error('Failed to submit pre-quote response:', error) + return { + success: false, + error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.' + } + } +} + +// Partners에서 사전견적 참여 의사 결정 (참여/미참여) +export async function respondToPreQuoteInvitation( + biddingCompanyId: number, + response: 'pre_quote_accepted' | 'pre_quote_declined' +) { + try { + await db.update(biddingCompanies) + .set({ + invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = response === 'pre_quote_accepted' ? + '사전견적 참여를 수락했습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to respond to pre-quote invitation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.' + } + } +} + +// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용) +export async function setPreQuoteParticipation( + biddingCompanyId: number, + isParticipating: boolean +) { + try { + await db.update(biddingCompanies) + .set({ + isPreQuoteParticipated: isParticipating, + respondedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + const message = isParticipating ? + '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' : + '사전견적 참여를 거절했습니다.' + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to set pre-quote participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.' + } + } +} + +// PR 아이템 조회 (입찰에 포함된 품목들) +export async function getPrItemsForBidding(biddingId: number) { + try { + const prItems = await db + .select({ + id: prItemsForBidding.id, + biddingId: prItemsForBidding.biddingId, + itemNumber: prItemsForBidding.itemNumber, + projectId: prItemsForBidding.projectId, + projectInfo: prItemsForBidding.projectInfo, + itemInfo: prItemsForBidding.itemInfo, + shi: prItemsForBidding.shi, + materialGroupNumber: prItemsForBidding.materialGroupNumber, + materialGroupInfo: prItemsForBidding.materialGroupInfo, + materialNumber: prItemsForBidding.materialNumber, + materialInfo: prItemsForBidding.materialInfo, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, + annualUnitPrice: prItemsForBidding.annualUnitPrice, + currency: prItemsForBidding.currency, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, + priceUnit: prItemsForBidding.priceUnit, + purchaseUnit: prItemsForBidding.purchaseUnit, + materialWeight: prItemsForBidding.materialWeight, + prNumber: prItemsForBidding.prNumber, + hasSpecDocument: prItemsForBidding.hasSpecDocument, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + return prItems + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } +} + +// SPEC 문서 조회 (PR 아이템에 연결된 문서들) +export async function getSpecDocumentsForPrItem(prItemId: number) { + try { + + const specDocs = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.prItemId, prItemId), + eq(biddingDocuments.documentType, 'spec_document') + ) + ) + + return specDocs + } catch (error) { + console.error('Failed to get spec documents for PR item:', error) + return [] + } +} + +// 사전견적 임시저장 +export async function savePreQuoteDraft( + biddingCompanyId: number, + responseData: { + prItemQuotations?: PrItemQuotation[] + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + isInitialResponse?: boolean + sparePartResponse?: string + additionalProposals?: string + priceAdjustmentForm?: any + }, + userId: string +) { + try { + let totalAmount = 0 + console.log('responseData', responseData) + + await db.transaction(async (tx) => { + // 품목별 견적 정보 저장 + if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) { + // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기) + await tx.delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // 새로운 품목별 견적 저장 + for (const item of responseData.prItemQuotations) { + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + isPreQuote: true, // 사전견적 표시 + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + } + + // 총 금액 계산 + totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0) + + // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음) + await tx.update(biddingCompanies) + .set({ + preQuoteAmount: totalAmount.toString(), + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + } + + // company_condition_responses 업데이트 (임시저장) + const conditionResult = await tx.update(companyConditionResponses) + .set({ + paymentTermsResponse: responseData.paymentTermsResponse || null, + taxConditionsResponse: responseData.taxConditionsResponse || null, + incotermsResponse: responseData.incotermsResponse || null, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null, + proposedShippingPort: responseData.proposedShippingPort || null, + proposedDestinationPort: responseData.proposedDestinationPort || null, + priceAdjustmentResponse: responseData.priceAdjustmentResponse || null, + isInitialResponse: responseData.isInitialResponse || null, + sparePartResponse: responseData.sparePartResponse || null, + additionalProposals: responseData.additionalProposals || null, + updatedAt: new Date() + }) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .returning() + + // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) { + const companyConditionResponseId = conditionResult[0].id + + const priceAdjustmentData = { + companyConditionResponsesId: companyConditionResponseId, + itemName: responseData.priceAdjustmentForm.itemName, + adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint, + majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, + adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, + rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, + referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, + notes: responseData.priceAdjustmentForm.notes, + adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, + majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, + adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, + contractorWriter: responseData.priceAdjustmentForm.contractorWriter, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, + nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, + } as any + + // 기존 연동제 정보가 있는지 확인 + 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) + } + } + }) + + return { + success: true, + message: '임시저장이 완료되었습니다.', + totalAmount + } + } catch (error) { + console.error('Failed to save pre-quote draft:', error) + return { + success: false, + error: error instanceof Error ? error.message : '임시저장에 실패했습니다.' + } + } +} + +// 견적 문서 업로드 +export async function uploadPreQuoteDocument( + biddingId: number, + companyId: number, + file: File, + userId: string +) { + try { + const userName = await getUserNameById(userId) + // 파일 저장 + const saveResult = await saveFile({ + file, + directory: `bidding/${biddingId}/quotations`, + originalName: file.name, + userId + }) + + if (!saveResult.success) { + return { + success: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + } + } + + // 데이터베이스에 문서 정보 저장 + const result = await db.insert(biddingDocuments) + .values({ + biddingId, + companyId, + documentType: 'other', // 견적서 타입 + fileName: saveResult.fileName!, + originalFileName: file.name, + fileSize: file.size, + mimeType: file.type, + filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로) + title: `견적서 - ${file.name}`, + description: '협력업체 제출 견적서', + isPublic: false, + isRequired: false, + uploadedBy: userName, + uploadedAt: new Date() + }) + .returning() + + return { + success: true, + message: '견적서가 성공적으로 업로드되었습니다.', + documentId: result[0].id + } + } catch (error) { + console.error('Failed to upload pre-quote document:', error) + return { + success: false, + error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.' + } + } +} + +// 업로드된 견적 문서 목록 조회 +export async function getPreQuoteDocuments(biddingId: number, companyId: number) { + try { + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + ) + ) + + return documents + } catch (error) { + console.error('Failed to get pre-quote documents:', error) + return [] + } + } + +// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용) +export async function getSavedPrItemQuotations(biddingCompanyId: number) { + try { + const savedQuotations = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + technicalSpecification: companyPrItemBids.technicalSpecification, + currency: companyPrItemBids.currency + }) + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, true) + ) + ) + + // Decimal 타입을 number로 변환 + return savedQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), + bidAmount: parseFloat(item.bidAmount || '0'), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: item.currency + })) + } catch (error) { + console.error('Failed to get saved PR item quotations:', error) + return [] + } + } + +// 견적 문서 정보 조회 (다운로드용) +export async function getPreQuoteDocumentForDownload( + documentId: number, + biddingId: number, + companyId: number +) { + try { + const document = await db + .select({ + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + filePath: biddingDocuments.filePath + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + eq(biddingDocuments.documentType, 'other') + ) + ) + .limit(1) + + if (document.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + return { + success: true, + document: document[0] + } + } catch (error) { + console.error('Failed to get pre-quote document:', error) + return { + success: false, + error: '문서 정보 조회에 실패했습니다.' + } + } +} + +// 견적 문서 삭제 +export async function deletePreQuoteDocument( + documentId: number, + biddingId: number, + companyId: number, + userId: string +) { + try { + // 문서 존재 여부 및 권한 확인 + const document = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + filePath: biddingDocuments.filePath, + uploadedBy: biddingDocuments.uploadedBy + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + eq(biddingDocuments.documentType, 'other') + ) + ) + .limit(1) + + if (document.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + const doc = document[0] + + // 데이터베이스에서 문서 정보 삭제 + await db + .delete(biddingDocuments) + .where(eq(biddingDocuments.id, documentId)) + + return { + success: true, + message: '문서가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete pre-quote document:', error) + return { + success: false, + error: '문서 삭제에 실패했습니다.' + } + } + } + +// 기본계약 발송 (서버 액션) +export async function sendBiddingBasicContracts( + biddingId: number, + vendorData: Array<{ + vendorId: number + vendorName: string + vendorCode?: string + vendorCountry?: string + selectedMainEmail: string + additionalEmails: string[] + customEmails?: Array<{ email: string; name?: string }> + contractRequirements: { + ndaYn: boolean + generalGtcYn: boolean + projectGtcYn: boolean + agreementYn: boolean + } + biddingCompanyId: number + biddingId: number + hasExistingContracts?: boolean + }>, + generatedPdfs: Array<{ + key: string + buffer: number[] + fileName: string + }>, + message?: string +) { + try { + console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) }); + + // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용) + const [currentUser] = await db.select().from(users).limit(1) + + if (!currentUser) { + throw new Error("사용자 정보를 찾을 수 없습니다.") + } + + const results = [] + const savedContracts = [] + + // 트랜잭션 시작 + 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) { + // 기존 계약 확인 (biddingCompanyId 기준) + if (vendor.hasExistingContracts) { + console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`) + continue + } + + // 벤더 정보 조회 + const [vendorInfo] = await tx + .select() + .from(vendors) + .where(eq(vendors.id, vendor.vendorId)) + .limit(1) + + if (!vendorInfo) { + console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`) + continue + } + + // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용) + console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`) + let [biddingCompanyInfo] = await tx + .select() + .from(biddingCompanies) + .where(eq(biddingCompanies.id, vendor.biddingCompanyId)) + .limit(1) + + console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo) + if (!biddingCompanyInfo) { + console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`) + // fallback: biddingId와 vendorId로 찾기 시도 + console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`) + const [fallbackCompanyInfo] = await tx + .select() + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, vendor.vendorId) + )) + .limit(1) + console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo) + if (fallbackCompanyInfo) { + console.log(`Using fallback biddingCompanyInfo`) + biddingCompanyInfo = fallbackCompanyInfo + } else { + console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10)) + continue + } + } + + // 계약 요구사항에 따라 계약서 생성 + const contractTypes: Array<{ type: string; templateName: string }> = [] + if (vendor.contractRequirements?.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' }) + if (vendor.contractRequirements?.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' }) + if (vendor.contractRequirements?.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' }) + if (vendor.contractRequirements?.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' }) + + // contractRequirements가 없거나 빈 객체인 경우 빈 배열로 처리 + if (!vendor.contractRequirements || Object.keys(vendor.contractRequirements).length === 0) { + console.log(`Skipping vendor ${vendor.vendorId} - no contract requirements specified`) + continue + } + + console.log("contractTypes", contractTypes) + for (const contractType of contractTypes) { + // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기) + console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key)) + const pdfData = generatedPdfs.find((pdf: any) => + pdf.key.includes(`${vendor.vendorId}_`) && + pdf.key.includes(`_${contractType.templateName}`) + ) + console.log("pdfData", pdfData, "for contractType", contractType) + if (!pdfData) { + console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`) + continue + } + + // 파일 저장 (rfq-last 방식) + const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf` + const filePath = path.join(contractsDir, fileName); + + await writeFile(filePath, Buffer.from(pdfData.buffer)); + + // 템플릿 정보 조회 (rfq-last 방식) + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + console.log("템플릿", contractType.templateName, template); + + // 기존 계약이 있는지 확인 (rfq-last 방식) + const [existingContract] = await tx + .select() + .from(basicContract) + .where( + and( + eq(basicContract.templateId, template?.id), + eq(basicContract.vendorId, vendor.vendorId), + eq(basicContract.biddingCompanyId, biddingCompanyInfo.id) + ) + ) + .limit(1); + + let contractRecord; + + if (existingContract) { + // 기존 계약이 있으면 업데이트 + [contractRecord] = await tx + .update(basicContract) + .set({ + requestedBy: currentUser.id, + status: "PENDING", // 재발송 상태 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }) + .where(eq(basicContract.id, existingContract.id)) + .returning(); + + console.log("기존 계약 업데이트:", contractRecord.id); + } else { + // 새 계약 생성 + [contractRecord] = await tx + .insert(basicContract) + .values({ + templateId: template?.id || null, + vendorId: vendor.vendorId, + biddingCompanyId: biddingCompanyInfo.id, + rfqCompanyId: null, + generalContractId: null, + requestedBy: currentUser.id, + status: 'PENDING', + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후 + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log("새 계약 생성:", contractRecord.id); + } + + results.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + contractId: contractRecord.id, + contractType: contractType.type, + 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, // 업데이트 여부 표시 + // }) + } + + // 이메일 발송 (선택사항) + if (vendor.selectedMainEmail) { + try { + await sendEmail({ + to: vendor.selectedMainEmail, + template: 'basic-contract-notification', + context: { + vendorName: vendor.vendorName, + biddingId: biddingId, + contractCount: contractTypes.length, + deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'), + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`, + message: message || '', + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + } catch (emailError) { + console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError) + // 이메일 발송 실패해도 계약 생성은 유지 + } + } + } + + return { + success: true, + message: `${results.length}개의 기본계약이 생성되었습니다.`, + results, + savedContracts, + totalContracts: savedContracts.length, + } + }) + + return result + + } catch (error) { + console.error('기본계약 발송 실패:', error) + throw new Error( + error instanceof Error + ? error.message + : '기본계약 발송 중 오류가 발생했습니다.' + ) + } +} + +// 기존 기본계약 조회 (서버 액션) +export async function getExistingBasicContractsForBidding(biddingId: number) { + try { + // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회 + const existingContracts = await db + .select({ + id: basicContract.id, + vendorId: basicContract.vendorId, + biddingCompanyId: basicContract.biddingCompanyId, + biddingId: biddingCompanies.biddingId, + templateId: basicContract.templateId, + status: basicContract.status, + createdAt: basicContract.createdAt, + }) + .from(basicContract) + .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + ) + ) + + return { + success: true, + contracts: existingContracts + } + + } catch (error) { + console.error('기존 계약 조회 실패:', error) + return { + success: false, + error: '기존 계약 조회에 실패했습니다.' + } + } +} + +// 선정된 업체들 조회 (서버 액션) +export async function getSelectedVendorsForBidding(biddingId: number) { + try { + const selectedCompanies = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + companyEmail: vendors.email, + companyCountry: vendors.country, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + biddingId: biddingCompanies.biddingId, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) + )) + + return { + success: true, + vendors: selectedCompanies.map(company => ({ + vendorId: company.companyId, // 실제 vendor ID + vendorName: company.companyName || '', + vendorCode: company.companyCode, + vendorEmail: company.companyEmail, + vendorCountry: company.companyCountry || '대한민국', + contactPerson: company.contactPerson, + contactEmail: company.contactEmail, + biddingCompanyId: company.id, // biddingCompany ID + biddingId: company.biddingId, + ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정) + generalGtcYn: true, + projectGtcYn: true, + agreementYn: true + })) + } + } catch (error) { + console.error('선정된 업체 조회 실패:', error) + return { + success: false, + error: '선정된 업체 조회에 실패했습니다.', + vendors: [] + } + } } \ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx deleted file mode 100644 index cfa629e3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx +++ /dev/null @@ -1,224 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - FileText, - Download, - User, - Calendar -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service' -import { downloadFile } from '@/lib/file-download' - -interface UploadedDocument { - id: number - fileName: string - originalFileName: string - fileSize: number | null - filePath: string - title: string | null - description: string | null - uploadedAt: string - uploadedBy: string -} - -interface BiddingPreQuoteAttachmentsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - companyId: number - companyName: string -} - -export function BiddingPreQuoteAttachmentsDialog({ - open, - onOpenChange, - biddingId, - companyId, - companyName -}: BiddingPreQuoteAttachmentsDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [documents, setDocuments] = React.useState([]) - const [isLoading, setIsLoading] = React.useState(false) - - // 다이얼로그가 열릴 때 첨부파일 목록 로드 - React.useEffect(() => { - if (open) { - loadDocuments() - } - }, [open, biddingId, companyId]) - - const loadDocuments = async () => { - setIsLoading(true) - try { - const docs = await getPreQuoteDocuments(biddingId, companyId) - // Date를 string으로 변환 - const mappedDocs = docs.map(doc => ({ - ...doc, - uploadedAt: doc.uploadedAt.toString(), - uploadedBy: doc.uploadedBy || '' - })) - setDocuments(mappedDocs) - } catch (error) { - console.error('Failed to load documents:', error) - toast({ - title: '오류', - description: '첨부파일 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - - // 파일 다운로드 - const handleDownload = (document: UploadedDocument) => { - startTransition(async () => { - const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId) - - if (result.success) { - try { - await downloadFile(result.document?.filePath, result.document?.originalFileName, { - showToast: true - }) - } catch (error) { - toast({ - title: '다운로드 실패', - description: '파일 다운로드에 실패했습니다.', - variant: 'destructive', - }) - } - } else { - toast({ - title: '다운로드 실패', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - // 파일 크기 포맷팅 - const formatFileSize = (bytes: number | null) => { - if (!bytes) return '-' - if (bytes === 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - return ( - - - - - - 협력업체 첨부파일 - - - {companyName} - - - - 협력업체가 제출한 견적 관련 첨부파일 목록입니다. - - - - {isLoading ? ( -
-
-
-

첨부파일 목록을 불러오는 중...

-
-
- ) : documents.length > 0 ? ( -
-
- - 총 {documents.length}개 파일 - -
- - - - - 파일명 - 크기 - 업로드일 - 작성자 - 작업 - - - - {documents.map((doc) => ( - - -
- - - {doc.originalFileName} - -
-
- - {formatFileSize(doc.fileSize)} - - -
- - {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} -
-
- -
- - {doc.uploadedBy} -
-
- - - -
- ))} -
-
-
- ) : ( -
- -

첨부파일이 없습니다

-

협력업체가 아직 첨부파일을 업로드하지 않았습니다.

-
- )} -
-
- ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx deleted file mode 100644 index 91b80bd3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails } from '@/lib/bidding/detail/service' -import { getBiddingCompanies } from '../service' - -import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' - -interface BiddingPreQuoteContentProps { - bidding: Bidding - quotationDetails: QuotationDetails | null - biddingCompanies: any[] - prItems: any[] -} - -export function BiddingPreQuoteContent({ - bidding, - quotationDetails, - biddingCompanies: initialBiddingCompanies, - prItems -}: BiddingPreQuoteContentProps) { - const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies) - const [refreshTrigger, setRefreshTrigger] = React.useState(0) - - const handleRefresh = React.useCallback(async () => { - try { - const result = await getBiddingCompanies(bidding.id) - if (result.success && result.data) { - setBiddingCompanies(result.data) - } - setRefreshTrigger(prev => prev + 1) - } catch (error) { - console.error('Failed to refresh bidding companies:', error) - } - }, [bidding.id]) - - return ( -
- {}} - onOpenTargetPriceDialog={() => {}} - onOpenSelectionReasonDialog={() => {}} - /> -
- ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx deleted file mode 100644 index 3205df08..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx +++ /dev/null @@ -1,770 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Badge } from '@/components/ui/badge' -import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service' -import { getActiveContractTemplates } from '../../service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react' -import { Progress } from '@/components/ui/progress' -import { Separator } from '@/components/ui/separator' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { cn } from '@/lib/utils' - -interface BiddingPreQuoteInvitationDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - companies: BiddingCompany[] - biddingId: number - biddingTitle: string - projectName?: string - onSuccess: () => void -} - -interface BasicContractTemplate { - id: number - templateName: string - revision: number - status: string - filePath: string | null - validityPeriod: number | null - legalReviewRequired: boolean - createdAt: Date | null -} - -interface SelectedContract { - templateId: number - templateName: string - contractType: string // templateName을 contractType으로 사용 - checked: boolean -} - -// PDF 생성 유틸리티 함수 -const generateBasicContractPdf = async ( - template: BasicContractTemplate, - vendorId: number -): Promise<{ buffer: number[]; fileName: string }> => { - try { - // 1. 템플릿 데이터 준비 (서버 API 호출) - const prepareResponse = await fetch("/api/contracts/prepare-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templateName: template.templateName, - vendorId, - }), - }); - - if (!prepareResponse.ok) { - throw new Error("템플릿 준비 실패"); - } - - const { template: preparedTemplate, templateData } = await prepareResponse.json(); - - // 2. 템플릿 파일 다운로드 - const templateResponse = await fetch("/api/contracts/get-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ templatePath: preparedTemplate.filePath }), - }); - - const templateBlob = await templateResponse.blob(); - const templateFile = new window.File([templateBlob], "template.docx", { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - }); - - // 3. PDFTron WebViewer로 PDF 변환 - const { default: WebViewer } = await import("@pdftron/webviewer"); - - const tempDiv = document.createElement('div'); - tempDiv.style.display = 'none'; - document.body.appendChild(tempDiv); - - try { - const instance = await WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - tempDiv - ); - - const { Core } = instance; - const { createDocument } = Core; - - const templateDoc = await createDocument(templateFile, { - filename: templateFile.name, - extension: 'docx', - }); - - // 변수 치환 적용 - await templateDoc.applyTemplateValues(templateData); - - // PDF 변환 - const fileData = await templateDoc.getFileData(); - const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); - - const fileName = `${template.templateName}_${Date.now()}.pdf`; - - return { - buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 - fileName - }; - - } finally { - if (tempDiv.parentNode) { - document.body.removeChild(tempDiv); - } - } - } catch (error) { - console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error); - throw error; - } -}; - -export function BiddingPreQuoteInvitationDialog({ - open, - onOpenChange, - companies, - biddingId, - biddingTitle, - projectName, - onSuccess -}: BiddingPreQuoteInvitationDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompanyIds, setSelectedCompanyIds] = React.useState([]) - const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('') - const [additionalMessage, setAdditionalMessage] = React.useState('') - - // 기본계약 관련 상태 - const [existingContracts, setExistingContracts] = React.useState([]) - const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) - const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) - const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') - - // 기본계약서 템플릿 관련 상태 - const [availableTemplates, setAvailableTemplates] = React.useState([]) - const [selectedContracts, setSelectedContracts] = React.useState([]) - const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) - - // 초대 가능한 업체들 (pending 상태인 업체들) - const invitableCompanies = React.useMemo(() => companies.filter(company => - company.invitationStatus === 'pending' && company.companyName - ), [companies]) - - // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드 - React.useEffect(() => { - if (open) { - const fetchInitialData = async () => { - setIsLoadingTemplates(true); - try { - const [contractsResult, templatesData] = await Promise.all([ - getExistingBasicContractsForBidding(biddingId), - getActiveContractTemplates() - ]); - - // 기존 계약 조회 - 서버 액션 사용 - const existingContractsResult = await getExistingBasicContractsForBidding(biddingId); - setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []); - - // 템플릿 로드 (4개 타입만 필터링) - // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 - const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; - const filteredTemplates = (templatesData.templates || []).filter((template: any) => - allowedTemplateNames.some(allowedName => - template.templateName.includes(allowedName) || - allowedName.includes(template.templateName) - ) - ); - setAvailableTemplates(filteredTemplates as BasicContractTemplate[]); - const initialSelected = filteredTemplates.map((template: any) => ({ - templateId: template.id, - templateName: template.templateName, - contractType: template.templateName, - checked: false - })); - setSelectedContracts(initialSelected); - - } catch (error) { - console.error('초기 데이터 로드 실패:', error); - toast({ - title: '오류', - description: '기본 정보를 불러오는 데 실패했습니다.', - variant: 'destructive', - }); - setExistingContracts([]); - setAvailableTemplates([]); - setSelectedContracts([]); - } finally { - setIsLoadingTemplates(false); - } - } - fetchInitialData(); - } - }, [open, biddingId, toast]); - - const handleSelectAll = (checked: boolean | 'indeterminate') => { - if (checked) { - // 기존 계약이 없는 업체만 선택 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ) - setSelectedCompanyIds(availableCompanies.map(company => company.id)) - } else { - setSelectedCompanyIds([]) - } - } - - const handleSelectCompany = (companyId: number, checked: boolean) => { - const company = invitableCompanies.find(c => c.id === companyId) - const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false - - if (hasExistingContract) { - toast({ - title: '선택 불가', - description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.', - variant: 'default', - }) - return - } - - if (checked) { - setSelectedCompanyIds(prev => [...prev, companyId]) - } else { - setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) - } - } - - // 기본계약서 선택 토글 - const toggleContractSelection = (templateId: number) => { - setSelectedContracts(prev => - prev.map(contract => - contract.templateId === templateId - ? { ...contract, checked: !contract.checked } - : contract - ) - ) - } - - // 모든 기본계약서 선택/해제 - const toggleAllContractSelection = (checked: boolean | 'indeterminate') => { - setSelectedContracts(prev => - prev.map(contract => ({ ...contract, checked: !!checked })) - ) - } - - const handleSendInvitations = () => { - if (selectedCompanyIds.length === 0) { - toast({ - title: '알림', - description: '초대를 발송할 업체를 선택해주세요.', - variant: 'default', - }) - return - } - - const selectedContractTemplates = selectedContracts.filter(c => c.checked); - const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - const vendorsToGenerateContracts = companiesForContracts.filter(company => - !existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ) - ); - - startTransition(async () => { - try { - // 1. 사전견적 초대 발송 - const invitationResponse = await sendPreQuoteInvitations( - selectedCompanyIds, - preQuoteDeadline || undefined - ) - - if (!invitationResponse.success) { - toast({ - title: '초대 발송 실패', - description: invitationResponse.error, - variant: 'destructive', - }) - return - } - - // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우) - let contractResponse: Awaited> | null = null - if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) { - setIsGeneratingPdfs(true) - setPdfGenerationProgress(0) - - const generatedPdfsMap = new Map() - - let generatedCount = 0; - for (const vendor of vendorsToGenerateContracts) { - for (const contract of selectedContractTemplates) { - setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`); - const templateDetails = availableTemplates.find(t => t.id === contract.templateId); - - if (templateDetails) { - const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId); - // sendBiddingBasicContracts와 동일한 키 형식 사용 - let contractType = ''; - if (contract.templateName.includes('비밀')) { - contractType = 'NDA'; - } else if (contract.templateName.includes('General GTC')) { - contractType = 'General_GTC'; - } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) { - contractType = 'Project_GTC'; - } else if (contract.templateName.includes('기술자료')) { - contractType = '기술자료'; - } - const key = `${vendor.companyId}_${contractType}_${contract.templateName}`; - generatedPdfsMap.set(key, pdfData); - } - } - generatedCount++; - setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100); - } - - setIsGeneratingPdfs(false); - - const vendorData = companiesForContracts.map(company => { - // 선택된 템플릿에 따라 contractRequirements 동적으로 설정 - const contractRequirements = { - ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')), - generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')), - projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')), - agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료')) - }; - - return { - vendorId: company.companyId, - vendorName: company.companyName || '', - vendorCode: company.companyCode, - vendorCountry: '대한민국', - selectedMainEmail: company.contactEmail || '', - contactPerson: company.contactPerson, - contactEmail: company.contactEmail, - biddingCompanyId: company.id, - biddingId: biddingId, - hasExistingContracts: existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ), - contractRequirements, - additionalEmails: [], - customEmails: [] - }; - }); - - const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ - key, - buffer: data.buffer, - fileName: data.fileName, - })); - - console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId); - console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId }))); - - contractResponse = await sendBiddingBasicContracts( - biddingId, - vendorData, - pdfsArray, - additionalMessage - ); - } - - let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.'; - if (contractResponse && contractResponse.success) { - successMessage += `\n${contractResponse.message}`; - } - - toast({ - title: '성공', - description: successMessage, - }) - - // 상태 초기화 - setSelectedCompanyIds([]); - setPreQuoteDeadline(''); - setAdditionalMessage(''); - setExistingContracts([]); - setIsGeneratingPdfs(false); - setPdfGenerationProgress(0); - setCurrentGeneratingContract(''); - setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false }))); - - onOpenChange(false); - onSuccess(); - - } catch (error) { - console.error('발송 실패:', error); - toast({ - title: '오류', - description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - variant: 'destructive', - }); - setIsGeneratingPdfs(false); - } - }) - } - - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - setSelectedCompanyIds([]) - setPreQuoteDeadline('') - setAdditionalMessage('') - setExistingContracts([]) - setIsGeneratingPdfs(false) - setPdfGenerationProgress(0) - setCurrentGeneratingContract('') - setSelectedContracts([]) - } - } - - const selectedContractCount = selectedContracts.filter(c => c.checked).length; - const selectedCompanyCount = selectedCompanyIds.length; - const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - // 기존 계약이 없는 업체들만 계산 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ); - const selectedAvailableCompanyCount = selectedCompanyIds.filter(id => - availableCompanies.some(company => company.id === id) - ).length; - - // 선택된 업체들 중 기존 계약이 있는 업체들 - const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company => - selectedCompanyIds.includes(company.id) && - existingContracts.some(ec => ec.vendorId === company.companyId) - ); - - return ( - - - - - - 사전견적 초대 및 기본계약 발송 - - - 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다. - - - -
-
- {/* 견적 마감일 설정 */} -
- - setPreQuoteDeadline(e.target.value)} - className="w-full" - /> -
- - {/* 기존 계약 정보 알림 */} - {existingContracts.length > 0 && ( - - - 기존 계약 정보 - - 이미 기본계약을 받은 업체가 있습니다. - 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다. - - - )} - - {/* 업체 선택 섹션 */} - - - - - 초대 대상 업체 - - - - {invitableCompanies.length === 0 ? ( -
- 초대 가능한 업체가 없습니다. -
- ) : ( - <> -
-
- 0} - onCheckedChange={handleSelectAll} - /> - -
- - {selectedCompanyCount}개 선택됨 - -
- -
- {invitableCompanies.map((company) => { - const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId); - return ( -
- handleSelectCompany(company.id, !!checked)} - /> -
-
- - {company.companyName} - - - {company.companyCode} - - {hasExistingContract && ( - - - 계약 체결됨 - - )} -
- {hasExistingContract && ( -

- 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다. -

- )} -
-
- ) - })} -
- - )} -
-
- - {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */} - {selectedCompaniesWithExistingContracts.length > 0 && ( - - - 선택한 업체 중 제외될 업체 - - 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다. - 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다. -
- 실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체 -
-
- )} - - {/* 기본계약서 선택 섹션 */} - - - - - - 기본계약서 선택 (선택된 업체에만 발송) - - - - {isLoadingTemplates ? ( -
- -

기본계약서 템플릿을 불러오는 중...

-
- ) : ( -
- {selectedCompanyCount === 0 && ( - - - 알림 - - 기본계약서를 발송할 업체를 먼저 선택해주세요. - - - )} - {availableTemplates.length === 0 ? ( -
- -

사용 가능한 기본계약서 템플릿이 없습니다.

-
- ) : ( - <> -
-
- 0 && selectedContracts.every(c => c.checked)} - onCheckedChange={toggleAllContractSelection} - /> - -
- - {selectedContractCount}개 선택됨 - -
-
- {selectedContracts.map((contract) => ( -
toggleContractSelection(contract.templateId)} - > -
- toggleContractSelection(contract.templateId)} - /> -
- -

- {contract.contractType} -

-
-
-
- ))} -
- - )} - {selectedContractCount > 0 && ( -
-
- - - 선택된 기본계약서 ({selectedContractCount}개) - -
-
    - {selectedContracts.filter(c => c.checked).map((contract) => ( -
  • - {contract.templateName} -
  • - ))} -
-
- )} -
- )} -
-
- - {/* 추가 메시지 */} -
- -