summaryrefslogtreecommitdiff
path: root/lib/bidding/pre-quote/service.ts
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-01 19:54:27 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-01 19:54:27 +0900
commit4b5880064e2362baf85c91f33b2b44baecea3a7f (patch)
tree24b48163ecbf205023fcd565b0476a30e3079a9f /lib/bidding/pre-quote/service.ts
parent44b74ff4170090673b6eeacd8c528e0abf47b7aa (diff)
parentcd0ce0cbe8af8719a6f542098ec78f2a5c1222ce (diff)
Merge branch 'dujinkim' of https://github.com/DTS-Development/SHI_EVCP into dujinkim
Diffstat (limited to 'lib/bidding/pre-quote/service.ts')
-rw-r--r--lib/bidding/pre-quote/service.ts1143
1 files changed, 337 insertions, 806 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 0f938b24..08cb0e2c 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -2,16 +2,16 @@
import db from '@/db/db'
import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
-import { basicContractTemplates } from '@/db/schema'
+import { basicContractTemplates, rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory, rfqsLast, rfqPrItems, users } 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 { eq, inArray, and, ilike, sql, desc, like } 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'
+import { getDefaultDueDate } from '@/lib/rfq-last/service'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -151,50 +151,6 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa
}
}
-// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
-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) {
@@ -244,84 +200,6 @@ export async function deleteBiddingCompany(id: number) {
}
}
-// 특정 입찰의 참여 업체 목록 조회 (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 {
@@ -332,512 +210,6 @@ interface CompanyWithContacts {
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, companyId?: number) {
@@ -937,146 +309,6 @@ export async function getSpecDocumentsForPrItem(prItemId: number) {
}
}
-// 사전견적 임시저장
-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,
@@ -1165,40 +397,6 @@ export async function getPreQuoteDocuments(biddingId: number, companyId: number)
}
}
-// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
-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(
@@ -1673,4 +871,337 @@ export async function getSelectedVendorsForBidding(biddingId: number) {
vendors: []
}
}
-} \ No newline at end of file
+}
+
+//입찰 사전견적 생성 서버액션
+interface CreatePreQuoteRfqInput {
+ rfqType: string;
+ rfqTitle: string;
+ dueDate: Date;
+ picUserId: number;
+ projectId?: number;
+ remark?: string;
+ biddingNumber?: string;
+ biddingId?: number; // 추가
+ contractStartDate?: Date;
+ contractEndDate?: Date;
+ items: Array<{
+ itemCode: string;
+ itemName: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ materialCode?: string;
+ materialName?: string;
+ }>;
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ };
+ createdBy: number;
+ updatedBy: number;
+}
+
+export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. 구매 담당자 정보 조회
+ const picUser = await tx
+ .select({
+ name: users.name,
+ email: users.email,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0) {
+ throw new Error("구매 담당자를 찾을 수 없습니다");
+ }
+
+ // 2. userCode 확인 (3자리)
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)");
+ }
+
+ // 3. RFQ 코드 생성 (B + userCode + 00001)
+ const rfqCode = await generatePreQuoteRfqCode(userCode);
+
+ // 4. 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.items[0];
+
+ // 5. 마감일 기본값 설정 (입력값 없으면 생성일 + 7일)
+ const dueDate = input.dueDate || await getDefaultDueDate();
+
+ // 6. rfqsLast 테이블에 기본 정보 삽입
+ const [newRfq] = await tx
+ .insert(rfqsLast)
+ .values({
+ rfqCode,
+ rfqType: 'pre_bidding',
+ rfqTitle: input.rfqTitle,
+ status: "RFQ 생성",
+ dueDate: dueDate, // 마감일 기본값 설정
+ biddingNumber: input.biddingNumber || null,
+ contractStartDate: input.contractStartDate || null,
+ contractEndDate: input.contractEndDate || null,
+
+ // 프로젝트 정보 (선택사항)
+ projectId: input.projectId || null,
+
+ // 대표 아이템 정보
+ itemCode: representativeItem.materialCode || representativeItem.itemCode,
+ itemName: representativeItem.materialName || representativeItem.itemName,
+
+ // 담당자 정보
+ pic: input.picUserId,
+ picCode: userCode, // userCode를 picCode로 사용
+ picName: picUser[0].name || '',
+
+ // 기타 정보
+ remark: input.remark || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 7. rfqPrItems 테이블에 아이템들 삽입
+ const prItemsData = input.items.map((item, index) => ({
+ rfqsLastId: newRfq.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정
+ prNo: null, // 일반견적에서는 PR 번호를 null로 설정
+
+ // 자재그룹 정보
+ materialCategory: item.itemCode, // 자재그룹코드
+ materialDescription: item.itemName, // 자재그룹명
+
+ // 자재 정보
+ materialCode: item.materialCode, // SAP 자재코드
+ acc: item.materialName || null, // 자재명 (ACC 컬럼에 저장)
+ quantity: item.quantity, // 수량
+ uom: item.uom, // 단위
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+ remark: item.remark || null, // 비고
+ }));
+
+ await tx.insert(rfqPrItems).values(prItemsData);
+
+ // 8. 벤더 및 조건 생성 (biddingId가 있는 경우)
+ if (input.biddingId) {
+ // 입찰 조건 매핑
+ const rfqConditions = mapBiddingConditionsToRfqConditions(input.biddingConditions);
+
+ // 입찰 업체 조회
+ const biddingVendors = await tx
+ .select({
+ companyId: biddingCompanies.companyId,
+ })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, input.biddingId));
+
+ if (biddingVendors.length > 0) {
+ for (const vendor of biddingVendors) {
+ if (!vendor.companyId) continue;
+
+ // rfqLastDetails 생성
+ const [rfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: newRfq.id,
+ vendorsId: vendor.companyId,
+ currency: rfqConditions.currency,
+ paymentTermsCode: rfqConditions.paymentTermsCode || null,
+ incotermsCode: rfqConditions.incotermsCode || null,
+ incotermsDetail: rfqConditions.incotermsDetail || null,
+ deliveryDate: rfqConditions.deliveryDate || null,
+ taxCode: rfqConditions.taxCode || null,
+ placeOfShipping: rfqConditions.placeOfShipping || null,
+ placeOfDestination: rfqConditions.placeOfDestination || null,
+ materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ sparepartYn: rfqConditions.sparepartYn,
+ sparepartDescription: rfqConditions.sparepartDescription || null,
+ updatedBy: input.updatedBy,
+ createdBy: input.createdBy,
+ isLatest: true,
+ })
+ .returning();
+
+ // rfqLastVendorResponses 생성
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: newRfq.id,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.companyId,
+ status: '대기중',
+ responseVersion: 1,
+ isLatest: true,
+ participationStatus: '미응답',
+ currency: rfqConditions.currency,
+ // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
+ vendorCurrency: rfqConditions.currency,
+ vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
+ vendorIncotermsCode: rfqConditions.incotermsCode || null,
+ vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
+ vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
+ vendorTaxCode: rfqConditions.taxCode || null,
+ vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
+ vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
+ vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ vendorSparepartYn: rfqConditions.sparepartYn,
+ vendorSparepartDescription: rfqConditions.sparepartDescription || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+ .returning();
+
+ // 이력 기록
+ await tx
+ .insert(rfqLastVendorResponseHistory)
+ .values({
+ vendorResponseId: vendorResponse.id,
+ action: '생성',
+ newStatus: '대기중',
+ changeDetails: {
+ action: '사전견적용 일반견적 생성',
+ biddingId: input.biddingId,
+ conditions: rfqConditions,
+ },
+ performedBy: input.createdBy,
+ });
+ }
+ }
+ }
+
+ return newRfq;
+ });
+
+ return {
+ success: true,
+ message: "입찰 사전견적이 성공적으로 생성되었습니다",
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode,
+ },
+ };
+
+ } catch (error) {
+ console.error("입찰 사전견적 생성 오류:", error);
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error: "입찰 사전견적 생성 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// 사전견적(입찰) RFQ 코드 생성 (B+userCode(3자리)+일련번호5자리 형식)
+async function generatePreQuoteRfqCode(userCode: string): Promise<string> {
+ // circular dependency check: use dynamic import for schema if needed, but generatePreQuoteRfqCode is used inside the action
+ // rfqsLast is already imported at top.
+
+ try {
+ // 동일한 userCode를 가진 마지막 사전견적 번호 조회
+ const lastRfq = await db
+ .select({ rfqCode: rfqsLast.rfqCode })
+ .from(rfqsLast)
+ .where(
+ and(
+ like(rfqsLast.rfqCode, `B${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회
+ )
+ )
+ .orderBy(desc(rfqsLast.createdAt))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (lastRfq.length > 0 && lastRfq[0].rfqCode) {
+ // B+userCode(3자리)+일련번호(5자리) 형식에서 마지막 5자리 숫자 추출
+ const rfqCode = lastRfq[0].rfqCode;
+ const serialNumber = rfqCode.slice(-5); // 마지막 5자리 추출
+
+ // 숫자인지 확인하고 다음 번호 생성
+ if (/^\d{5}$/.test(serialNumber)) {
+ nextNumber = parseInt(serialNumber) + 1;
+ }
+ }
+
+ // 5자리 숫자로 패딩
+ const paddedNumber = String(nextNumber).padStart(5, '0');
+ return `B${userCode}${paddedNumber}`;
+ } catch (error) {
+ console.error("Error generating Pre-Quote RFQ code:", error);
+ // 에러 발생 시 타임스탬프 기반 코드 생성
+ const timestamp = Date.now().toString().slice(-5);
+ return `B${userCode}${timestamp}`;
+ }
+}
+
+// Helper function to map bidding conditions
+function mapBiddingConditionsToRfqConditions(conditions?: CreatePreQuoteRfqInput['biddingConditions']) {
+ if (!conditions) {
+ return {
+ currency: 'KRW',
+ paymentTermsCode: undefined,
+ incotermsCode: undefined,
+ incotermsDetail: undefined,
+ deliveryDate: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ materialPriceRelatedYn: false,
+ sparepartYn: false,
+ sparepartDescription: undefined,
+ vendorDeliveryDate: undefined
+ }
+ }
+
+ // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
+ let deliveryDate: Date | undefined = undefined
+ if (conditions.contractDeliveryDate) {
+ try {
+ const date = new Date(conditions.contractDeliveryDate)
+ if (!isNaN(date.getTime())) {
+ deliveryDate = date
+ }
+ } catch (error) {
+ console.warn('Failed to parse contractDeliveryDate:', error)
+ }
+ }
+
+ return {
+ currency: 'KRW', // 기본값
+ paymentTermsCode: conditions.paymentTerms || undefined,
+ incotermsCode: conditions.incoterms || undefined,
+ incotermsDetail: conditions.incotermsOption || undefined,
+ deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
+ vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
+ taxCode: conditions.taxConditions || undefined,
+ placeOfShipping: conditions.shippingPort || undefined,
+ placeOfDestination: conditions.destinationPort || undefined,
+ materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
+ sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
+ sparepartDescription: conditions.sparePartOptions || undefined,
+ }
+}