'use server' import db from '@/db/db' import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' import { basicContractTemplates, rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory, rfqsLast, rfqPrItems, users } from '@/db/schema' import { vendors } from '@/db/schema/vendors' import { sendEmail } from '@/lib/mail/sendEmail' 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 { 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 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 : '업체 삭제에 실패했습니다.' } } } // 선택된 업체들에게 사전견적 초대 발송 interface CompanyWithContacts { id: number companyId: number companyName: string selectedMainEmail: string additionalEmails: string[] } // PR 아이템 조회 (입찰에 포함된 품목들) export async function getPrItemsForBidding(biddingId: number, companyId?: number) { try { const selectFields: any = { 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, targetUnitPrice: prItemsForBidding.targetUnitPrice, targetAmount: prItemsForBidding.targetAmount, targetCurrency: prItemsForBidding.targetCurrency, budgetAmount: prItemsForBidding.budgetAmount, budgetCurrency: prItemsForBidding.budgetCurrency, actualAmount: prItemsForBidding.actualAmount, actualCurrency: prItemsForBidding.actualCurrency, prNumber: prItemsForBidding.prNumber, hasSpecDocument: prItemsForBidding.hasSpecDocument, specification: prItemsForBidding.specification, } if (companyId) { selectFields.bidUnitPrice = companyPrItemBids.bidUnitPrice selectFields.bidAmount = companyPrItemBids.bidAmount selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate selectFields.technicalSpecification = companyPrItemBids.technicalSpecification } let query = db.select(selectFields).from(prItemsForBidding) if (companyId) { query = query .leftJoin(biddingCompanies, and( eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.companyId, companyId) )) .leftJoin(companyPrItemBids, and( eq(companyPrItemBids.prItemId, prItemsForBidding.id), eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id) )) as any } query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any const prItems = await query 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 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 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, subject: `[eVCP] 기본계약서 서명 요청`, template: "contract-sign-request", context: { vendorName: vendor.vendorName, templateCount: contractTypes.length, templateName: contractTypes.map(ct => ct.templateName).join(', '), loginUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/basic-contract`, 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 { // 1. 입찰에 참여하는 모든 업체 조회 const companies = 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, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) .where(eq(biddingCompanies.biddingId, biddingId)) // 2. 각 업체의 담당자 정보 조회 const vendorsWithContacts = await Promise.all( companies.map(async (company) => { let contacts: any[] = [] if (company.companyId) { // biddingCompaniesContacts에서 담당자 조회 const contactsResult = await db .select({ id: biddingCompaniesContacts.id, contactName: biddingCompaniesContacts.contactName, contactEmail: biddingCompaniesContacts.contactEmail, contactNumber: biddingCompaniesContacts.contactNumber, }) .from(biddingCompaniesContacts) .where( and( eq(biddingCompaniesContacts.biddingId, biddingId), eq(biddingCompaniesContacts.vendorId, company.companyId) ) ) contacts = contactsResult } return { vendorId: company.companyId, vendorName: company.companyName || '', vendorCode: company.companyCode, vendorEmail: company.companyEmail, vendorCountry: company.companyCountry || '대한민국', contactPerson: company.contactPerson, contactEmail: company.contactEmail, biddingCompanyId: company.id, biddingId: company.biddingId, isPreQuoteSelected: company.isPreQuoteSelected, ndaYn: true, generalGtcYn: true, projectGtcYn: true, agreementYn: true, contacts: contacts // 담당자 목록 추가 } }) ) return { success: true, vendors: vendorsWithContacts } } catch (error) { console.error('입찰 참여 업체 조회 실패:', error) return { success: false, error: '입찰 참여 업체 조회에 실패했습니다.', vendors: [] } } } //입찰 사전견적 생성 서버액션 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 { // 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, } }