diff options
Diffstat (limited to 'lib/rfq-last/service.ts')
| -rw-r--r-- | lib/rfq-last/service.ts | 1865 |
1 files changed, 1497 insertions, 368 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ac7104df..7470428f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,12 +3,20 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema"; +import { paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { sendEmail } from "../mail/sendEmail"; +import fs from 'fs/promises' +import path from 'path' +import { addDays, format } from "date-fns" +import { ko, enUS } from "date-fns/locale" +import { generateBasicContractsForVendor } from "../basic-contract/gen-service"; +import { writeFile, mkdir } from "fs/promises"; + export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -141,6 +149,9 @@ export async function getRfqs(input: GetRfqsSchema) { } } +const isDevelopment = process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'test'; + const getRfqById = async (id: number): Promise<RfqsLastView | null> => { // 1) RFQ 단건 조회 const rfqsRes = await db @@ -215,15 +226,15 @@ export async function getRfqAllAttachments(rfqId: number) { .where(eq(rfqLastAttachments.rfqId, rfqId)) .orderBy(desc(rfqLastAttachments.createdAt)) - return { - data, - success: true + return { + data, + success: true } } catch (err) { console.error("getRfqAllAttachments error:", err) - return { - data: [], - success: false + return { + data: [], + success: false } } } @@ -740,7 +751,7 @@ export async function getRevisionHistory(attachmentId: number): Promise<{ }) .from(rfqLastAttachmentRevisions) .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); - + originalFileName = latestRevision?.originalFileName || null; } @@ -834,33 +845,33 @@ export async function getRfqVendorAttachments(rfqId: number) { vendorResponseId: rfqLastVendorAttachments.vendorResponseId, attachmentType: rfqLastVendorAttachments.attachmentType, documentNo: rfqLastVendorAttachments.documentNo, - + // 파일 정보 fileName: rfqLastVendorAttachments.fileName, originalFileName: rfqLastVendorAttachments.originalFileName, filePath: rfqLastVendorAttachments.filePath, fileSize: rfqLastVendorAttachments.fileSize, fileType: rfqLastVendorAttachments.fileType, - + // 파일 설명 description: rfqLastVendorAttachments.description, - + // 유효기간 validFrom: rfqLastVendorAttachments.validFrom, validTo: rfqLastVendorAttachments.validTo, - + // 업로드 정보 uploadedBy: rfqLastVendorAttachments.uploadedBy, uploadedAt: rfqLastVendorAttachments.uploadedAt, - + // 업로더 정보 uploadedByName: users.name, - + // 벤더 정보 vendorId: rfqLastVendorResponses.vendorId, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, - + // 응답 상태 responseStatus: rfqLastVendorResponses.status, responseVersion: rfqLastVendorResponses.responseVersion, @@ -876,7 +887,7 @@ export async function getRfqVendorAttachments(rfqId: number) { .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) return { - vendorData, + vendorData: data, vendorSuccess: true } } catch (err) { @@ -994,7 +1005,7 @@ export async function addVendorToRfq({ }); revalidatePath(`/rfq-last/${rfqId}/vendor`); - + return { success: true }; } catch (error) { console.error("Add vendor error:", error); @@ -1006,6 +1017,7 @@ export async function addVendorsToRfq({ rfqId, vendorIds, conditions, + contractRequirements, // 추가된 파라미터 }: { rfqId: number; vendorIds: number[]; @@ -1025,21 +1037,26 @@ export async function addVendorsToRfq({ firstDescription?: string; sparepartDescription?: string; } | null; + contractRequirements?: { // 추가된 타입 정의 + agreementYn?: boolean; + ndaYn?: boolean; + gtcType?: "general" | "project" | "none"; + } | null; }) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); + if (!session?.user) { - throw new Error("인증이 필요합니다.") + throw new Error("인증이 필요합니다."); } - - const userId = Number(session.user.id) - + + const userId = Number(session.user.id); + // 빈 배열 체크 if (!vendorIds || vendorIds.length === 0) { return { success: false, error: "벤더를 선택해주세요." }; } - + // 중복 체크 - 이미 추가된 벤더들 확인 const existingVendors = await db .select({ @@ -1052,25 +1069,40 @@ export async function addVendorsToRfq({ inArray(rfqLastDetails.vendorsId, vendorIds) ) ); - + const existingVendorIds = existingVendors.map(v => v.vendorId); const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); - + if (newVendorIds.length === 0) { - return { - success: false, - error: "모든 벤더가 이미 추가되어 있습니다." + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." }; } - + // 일부만 중복인 경우 경고 메시지 준비 const skippedCount = vendorIds.length - newVendorIds.length; - + // 트랜잭션으로 처리 const results = await db.transaction(async (tx) => { const addedVendors = []; - + for (const vendorId of newVendorIds) { + // 벤더 정보 조회 (국가 정보 확인용) + const [vendor] = await tx + .select({ + id: vendors.id, + country: vendors.country, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1); + + // 국외 업체인지 확인 + const isInternational = vendor?.country && + vendor.country !== "KR" && + vendor.country !== "한국"; + // conditions가 없는 경우 기본값 설정 const vendorConditions = conditions || { currency: "USD", @@ -1079,94 +1111,79 @@ export async function addVendorsToRfq({ deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 taxCode: "VV", }; - - // 1. rfqLastDetails에 벤더 추가 + + // contractRequirements 기본값 설정 + const defaultContractRequirements = { + agreementYn: true, + ndaYn: true, + gtcType: "none" as "general" | "project" | "none", + }; + + const finalContractRequirements = contractRequirements || defaultContractRequirements; + + // gtcType에 따라 generalGtcYn과 projectGtcYn 설정 + const generalGtcYn = isInternational && finalContractRequirements.gtcType === "general"; + const projectGtcYn = isInternational && finalContractRequirements.gtcType === "project"; + + // 국내 업체는 gtcType을 강제로 "none"으로 설정 + const gtcType = isInternational ? finalContractRequirements.gtcType : "none"; + + // 1. rfqLastDetails에 벤더 추가 (기본계약 정보 포함) const [detail] = await tx .insert(rfqLastDetails) .values({ rfqsLastId: rfqId, vendorsId: vendorId, ...vendorConditions, + // 기본계약 관련 필드 추가 + agreementYn: finalContractRequirements.agreementYn ?? true, + ndaYn: finalContractRequirements.ndaYn ?? true, + gtcType: gtcType, + generalGtcYn: generalGtcYn, + projectGtcYn: projectGtcYn, updatedBy: userId, + updatedAt: new Date(), }) .returning(); - - // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 - const [response] = await tx - .insert(rfqLastVendorResponses) - .values({ - rfqsLastId: rfqId, - rfqLastDetailsId: detail.id, - vendorId: vendorId, - status: "초대됨", - responseVersion: 1, - isLatest: true, - currency: vendorConditions.currency, - // 구매자 제시 조건 복사 (초기값) - vendorCurrency: vendorConditions.currency, - vendorPaymentTermsCode: vendorConditions.paymentTermsCode, - vendorIncotermsCode: vendorConditions.incotermsCode, - vendorIncotermsDetail: vendorConditions.incotermsDetail, - vendorDeliveryDate: vendorConditions.deliveryDate, - vendorContractDuration: vendorConditions.contractDuration, - vendorTaxCode: vendorConditions.taxCode, - vendorPlaceOfShipping: vendorConditions.placeOfShipping, - vendorPlaceOfDestination: vendorConditions.placeOfDestination, - vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, - vendorSparepartYn: vendorConditions.sparepartYn, - vendorFirstYn: vendorConditions.firstYn, - vendorFirstDescription: vendorConditions.firstDescription, - vendorSparepartDescription: vendorConditions.sparepartDescription, - createdBy: userId, - updatedBy: userId, - }) - .returning(); - - // 3. 이력 기록 - await tx.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: response.id, - action: "생성", - newStatus: "초대됨", - changeDetails: { - action: "벤더 초대", - conditions: vendorConditions, - batchAdd: true, - totalVendors: newVendorIds.length - }, - performedBy: userId, - }); - + addedVendors.push({ vendorId, detailId: detail.id, - responseId: response.id, + contractRequirements: { + agreementYn: detail.agreementYn, + ndaYn: detail.ndaYn, + gtcType: detail.gtcType, + generalGtcYn: detail.generalGtcYn, + projectGtcYn: detail.projectGtcYn, + } }); } - + return addedVendors; }); - - revalidatePath(`/rfq-last/${rfqId}/vendor`); - + + revalidatePath(`/evcp/rfq-last/${rfqId}/vendor`); + // 성공 메시지 구성 let message = `${results.length}개 벤더가 추가되었습니다.`; if (skippedCount > 0) { message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; } - - return { + + return { success: true, data: { added: results.length, skipped: skippedCount, message, + vendors: results, // 추가된 벤더 정보 반환 } }; } catch (error) { console.error("Add vendors error:", error); - return { - success: false, - error: "벤더 추가 중 오류가 발생했습니다." + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." }; } } @@ -1198,17 +1215,17 @@ export async function updateVendorConditionsBatch({ }) { try { const session = await getServerSession(authOptions) - + if (!session?.user) { throw new Error("인증이 필요합니다.") } - + const userId = Number(session.user.id) - + if (!vendorIds || vendorIds.length === 0) { return { success: false, error: "벤더를 선택해주세요." }; } - + // 트랜잭션으로 처리 await db.transaction(async (tx) => { // 1. rfqLastDetails 업데이트 @@ -1225,7 +1242,7 @@ export async function updateVendorConditionsBatch({ inArray(rfqLastDetails.vendorsId, vendorIds) ) ); - + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 const vendorConditions = Object.keys(conditions).reduce((acc, key) => { if (conditions[key] !== undefined) { @@ -1233,7 +1250,7 @@ export async function updateVendorConditionsBatch({ } return acc; }, {}); - + await tx .update(rfqLastVendorResponses) .set({ @@ -1248,7 +1265,7 @@ export async function updateVendorConditionsBatch({ eq(rfqLastVendorResponses.isLatest, true) ) ); - + // 3. 이력 기록 (각 벤더별로) const responses = await tx .select({ id: rfqLastVendorResponses.id }) @@ -1260,25 +1277,25 @@ export async function updateVendorConditionsBatch({ eq(rfqLastVendorResponses.isLatest, true) ) ); - + for (const response of responses) { await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: response.id, action: "조건변경", - changeDetails: { - action: "조건 일괄 업데이트", + changeDetails: { + action: "조건 일괄 업데이트", conditions, batchUpdate: true, - totalVendors: vendorIds.length + totalVendors: vendorIds.length }, performedBy: userId, }); } }); - + revalidatePath(`/rfq-last/${rfqId}/vendor`); - - return { + + return { success: true, data: { message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` @@ -1286,77 +1303,13 @@ export async function updateVendorConditionsBatch({ }; } catch (error) { console.error("Update vendor conditions error:", error); - return { - success: false, - error: "조건 업데이트 중 오류가 발생했습니다." + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." }; } } -// RFQ 발송 액션 -export async function sendRfqToVendors({ - rfqId, - vendorIds, -}: { - rfqId: number; - vendorIds: number[]; -}) { - try { - - const session = await getServerSession(authOptions) - - if (!session?.user) { - throw new Error("인증이 필요합니다.") - } - const userId = Number(session.user.id) - - // 벤더별 응답 상태 업데이트 - for (const vendorId of vendorIds) { - const [response] = await db - .select() - .from(rfqLastVendorResponses) - .where( - and( - eq(rfqLastVendorResponses.rfqsLastId, rfqId), - eq(rfqLastVendorResponses.vendorId, vendorId), - eq(rfqLastVendorResponses.isLatest, true) - ) - ) - .limit(1); - - if (response) { - // 상태 업데이트 - await db - .update(rfqLastVendorResponses) - .set({ - status: "작성중", - updatedBy: userId, - updatedAt: new Date(), - }) - .where(eq(rfqLastVendorResponses.id, response.id)); - - // 이력 기록 - await db.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: response.id, - action: "발송", - previousStatus: response.status, - newStatus: "작성중", - changeDetails: { action: "RFQ 발송" }, - performedBy: userId, - }); - } - } - - // TODO: 실제 이메일 발송 로직 - - revalidatePath(`/rfq-last/${rfqId}/vendor`); - - return { success: true, count: vendorIds.length }; - } catch (error) { - console.error("Send RFQ error:", error); - return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; - } -} // 벤더 삭제 액션 export async function removeVendorFromRfq({ @@ -1387,9 +1340,9 @@ export async function removeVendorFromRfq({ .limit(1); if (response && response.status !== "초대됨") { - return { - success: false, - error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." }; } @@ -1404,7 +1357,7 @@ export async function removeVendorFromRfq({ ); revalidatePath(`/rfq-last/${rfqId}/vendor`); - + return { success: true }; } catch (error) { console.error("Remove vendor error:", error); @@ -1462,7 +1415,7 @@ export async function updateVendorResponseStatus({ }); revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); - + return { success: true }; } catch (error) { console.error("Update status error:", error); @@ -1488,20 +1441,19 @@ export async function getRfqVendorResponses(rfqId: number) { .select({ id: rfqsLast.id, rfqCode: rfqsLast.rfqCode, - title: rfqsLast.title, + title: rfqsLast.rfqTitle, status: rfqsLast.status, - startDate: rfqsLast.startDate, - endDate: rfqsLast.endDate, + endDate: rfqsLast.dueDate, }) .from(rfqsLast) .where(eq(rfqsLast.id, rfqId)) .limit(1); if (!rfqData || rfqData.length === 0) { - return { - success: false, + return { + success: false, error: "RFQ를 찾을 수 없습니다.", - data: null + data: null }; } @@ -1510,7 +1462,7 @@ export async function getRfqVendorResponses(rfqId: number) { .select() .from(rfqLastDetails) .where(eq(rfqLastDetails.rfqsLastId, rfqId)) - .orderBy(desc(rfqLastDetails.version)); + .orderBy(desc(rfqLastDetails.updatedAt)); // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) const vendorResponsesData = await db @@ -1522,39 +1474,61 @@ export async function getRfqVendorResponses(rfqId: number) { responseVersion: rfqLastVendorResponses.responseVersion, isLatest: rfqLastVendorResponses.isLatest, status: rfqLastVendorResponses.status, - + + //참여 정보 + participationStatus: rfqLastVendorResponses.participationStatus, + participationRepliedAt: rfqLastVendorResponses.participationRepliedAt, + participationRepliedBy: rfqLastVendorResponses.participationRepliedBy, + nonParticipationReason: rfqLastVendorResponses.nonParticipationReason, + // 벤더 정보 vendorId: rfqLastVendorResponses.vendorId, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, vendorEmail: vendors.email, - + // 제출 정보 submittedAt: rfqLastVendorResponses.submittedAt, submittedBy: rfqLastVendorResponses.submittedBy, submittedByName: users.name, - + // 금액 정보 totalAmount: rfqLastVendorResponses.totalAmount, currency: rfqLastVendorResponses.currency, - + // 벤더 제안 조건 vendorCurrency: rfqLastVendorResponses.vendorCurrency, vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail, vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, - - // 초도품/Spare part 응답 + vendorTaxCode: rfqLastVendorResponses.vendorTaxCode, + vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping, + vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination, + + // 초도품/Spare part/연동제 응답 vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription, vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription, vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, - + vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn, + vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason, + + // 변경 사유 + currencyReason: rfqLastVendorResponses.currencyReason, + paymentTermsReason: rfqLastVendorResponses.paymentTermsReason, + deliveryDateReason: rfqLastVendorResponses.deliveryDateReason, + incotermsReason: rfqLastVendorResponses.incotermsReason, + taxReason: rfqLastVendorResponses.taxReason, + shippingReason: rfqLastVendorResponses.shippingReason, + // 비고 generalRemark: rfqLastVendorResponses.generalRemark, technicalProposal: rfqLastVendorResponses.technicalProposal, - + // 타임스탬프 createdAt: rfqLastVendorResponses.createdAt, updatedAt: rfqLastVendorResponses.updatedAt, @@ -1570,108 +1544,269 @@ export async function getRfqVendorResponses(rfqId: number) { ) .orderBy(desc(rfqLastVendorResponses.createdAt)); - if (!vendorResponsesData || vendorResponsesData.length === 0) { - return { - success: true, - data: [], - rfq: rfqData[0], - details: details, - }; - } + if (!vendorResponsesData || vendorResponsesData.length === 0) { + return { + success: true, + data: [], + rfq: rfqData[0], + details: details, + }; + } - // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 - const vendorResponsesWithCounts = await Promise.all( + // 4. 각 벤더별 총 응답 수 조회 (모든 버전 포함) + const vendorResponseCounts = await db + .select({ + vendorId: rfqLastVendorResponses.vendorId, + responseCount: count(), + }) + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .groupBy(rfqLastVendorResponses.vendorId); + + // vendorId를 키로 하는 Map 생성 + const responseCountMap = new Map( + vendorResponseCounts.map(item => [item.vendorId, item.responseCount]) + ); + + // 5. 각 벤더 응답별 상세 정보 조회 (견적 아이템, 첨부파일) + const vendorResponsesWithDetails = await Promise.all( vendorResponsesData.map(async (response) => { - // 견적 아이템 수 조회 - const itemCount = await db - .select({ count: count()}) + // 견적 아이템 상세 조회 + const quotationItems = await db + .select({ + id: rfqLastVendorQuotationItems.id, + vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, + rfqPrItemId: rfqLastVendorQuotationItems.rfqPrItemId, + + // PR 아이템 정보 + prNo: rfqLastVendorQuotationItems.prNo, + materialCode: rfqLastVendorQuotationItems.materialCode, + materialDescription: rfqLastVendorQuotationItems.materialDescription, + + // 견적 정보 + quantity: rfqLastVendorQuotationItems.quantity, + uom: rfqLastVendorQuotationItems.uom, + unitPrice: rfqLastVendorQuotationItems.unitPrice, + totalPrice: rfqLastVendorQuotationItems.totalPrice, + currency: rfqLastVendorQuotationItems.currency, + + // 납기 정보 + vendorDeliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, + leadTime: rfqLastVendorQuotationItems.leadTime, + + // 제조사 정보 + manufacturer: rfqLastVendorQuotationItems.manufacturer, + manufacturerCountry: rfqLastVendorQuotationItems.manufacturerCountry, + modelNo: rfqLastVendorQuotationItems.modelNo, + + // 기술 사양 + technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, + alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, + + // 할인 정보 + discountRate: rfqLastVendorQuotationItems.discountRate, + discountAmount: rfqLastVendorQuotationItems.discountAmount, + + // 비고 + itemRemark: rfqLastVendorQuotationItems.itemRemark, + deviationReason: rfqLastVendorQuotationItems.deviationReason, + + createdAt: rfqLastVendorQuotationItems.createdAt, + updatedAt: rfqLastVendorQuotationItems.updatedAt, + }) .from(rfqLastVendorQuotationItems) - .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); - - // 첨부파일 수 조회 - const attachmentCount = await db - .select({ count: count()}) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)) + .orderBy(rfqLastVendorQuotationItems.id); + + // 첨부파일 조회 + const attachments = await db + .select({ + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + + // 첨부파일 구분 + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 (인증서 등) + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedByName: users.name, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + }) .from(rfqLastVendorAttachments) - .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)) + .orderBy(rfqLastVendorAttachments.attachmentType, rfqLastVendorAttachments.uploadedAt); + + // 해당 벤더의 총 응답 수 가져오기 + const vendorResponseCount = responseCountMap.get(response.vendorId) || 0; return { ...response, - quotedItemCount: itemCount[0]?.count || 0, - attachmentCount: attachmentCount[0]?.count || 0, + quotationItems, + attachments, + vendorResponseCount, }; }) ); - // 5. 응답 데이터 정리 - const formattedResponses = vendorResponsesWithCounts - .filter(response => response && response.id).map(response => ({ - id: response.id, - rfqsLastId: response.rfqsLastId, - rfqLastDetailsId: response.rfqLastDetailsId, - responseVersion: response.responseVersion, - isLatest: response.isLatest, - status: response.status || "초대됨", // 기본값 설정 - - // 벤더 정보 - vendor: { - id: response.vendorId, - code: response.vendorCode, - name: response.vendorName, - email: response.vendorEmail, - }, - - // 제출 정보 - submission: { - submittedAt: response.submittedAt, - submittedBy: response.submittedBy, - submittedByName: response.submittedByName, - }, - - // 금액 정보 - pricing: { - totalAmount: response.totalAmount, - currency: response.currency || "USD", - vendorCurrency: response.vendorCurrency, - }, - - // 벤더 제안 조건 - vendorTerms: { - paymentTermsCode: response.vendorPaymentTermsCode, - incotermsCode: response.vendorIncotermsCode, - deliveryDate: response.vendorDeliveryDate, - contractDuration: response.vendorContractDuration, - }, - - // 초도품/Spare part - additionalRequirements: { - firstArticle: { - required: response.vendorFirstYn, - acceptance: response.vendorFirstAcceptance, + // 6. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithDetails + .filter(response => response && response.id) + .map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status, + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + responseCount: response.vendorResponseCount, }, - sparePart: { - required: response.vendorSparepartYn, - acceptance: response.vendorSparepartAcceptance, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, }, - }, - - // 카운트 정보 - counts: { - quotedItems: response.quotedItemCount, - attachments: response.attachmentCount, - }, - - // 비고 - remarks: { - general: response.generalRemark, - technical: response.technicalProposal, - }, - - // 타임스탬프 - timestamps: { - createdAt: response.createdAt, - updatedAt: response.updatedAt, - }, - })); + + // 제출 정보 + attend: { + participationStatus: response.participationStatus, + participationRepliedAt: response.participationRepliedAt, + participationRepliedBy: response.participationRepliedBy, + nonParticipationReason: response.nonParticipationReason, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + incotermsDetail: response.vendorIncotermsDetail, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + taxCode: response.vendorTaxCode, + placeOfShipping: response.vendorPlaceOfShipping, + placeOfDestination: response.vendorPlaceOfDestination, + }, + + // 초도품/Spare part/연동제 + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + description: response.vendorFirstDescription, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + description: response.vendorSparepartDescription, + acceptance: response.vendorSparepartAcceptance, + }, + materialPriceRelated: { + required: response.vendorMaterialPriceRelatedYn, + reason: response.vendorMaterialPriceRelatedReason, + }, + }, + + // 변경 사유 + changeReasons: { + currency: response.currencyReason, + paymentTerms: response.paymentTermsReason, + deliveryDate: response.deliveryDateReason, + incoterms: response.incotermsReason, + tax: response.taxReason, + shipping: response.shippingReason, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotationItems.length, + attachments: response.attachments.length, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 견적 아이템 상세 + quotationItems: response.quotationItems.map(item => ({ + id: item.id, + rfqPrItemId: item.rfqPrItemId, + prNo: item.prNo, + materialCode: item.materialCode, + materialDescription: item.materialDescription, + quantity: item.quantity, + uom: item.uom, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + currency: item.currency, + vendorDeliveryDate: item.vendorDeliveryDate, + leadTime: item.leadTime, + manufacturer: item.manufacturer, + manufacturerCountry: item.manufacturerCountry, + modelNo: item.modelNo, + technicalCompliance: item.technicalCompliance, + alternativeProposal: item.alternativeProposal, + discountRate: item.discountRate, + discountAmount: item.discountAmount, + itemRemark: item.itemRemark, + deviationReason: item.deviationReason, + })), + + // 첨부파일 상세 + attachments: response.attachments.map(file => ({ + id: file.id, + attachmentType: file.attachmentType, + documentNo: file.documentNo, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, // 파일 경로 포함 + fileSize: file.fileSize, + fileType: file.fileType, + description: file.description, + validFrom: file.validFrom, + validTo: file.validTo, + uploadedBy: file.uploadedBy, + uploadedByName: file.uploadedByName, + uploadedAt: file.uploadedAt, + })), + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); return { success: true, @@ -1706,11 +1841,16 @@ export async function getRfqWithDetails(rfqId: number) { const details = await db .select() .from(rfqLastDetailsView) - .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .where( + and( + eq(rfqLastDetailsView.rfqId, rfqId), + eq(rfqLastDetailsView.isLatest, true) // isLatest 필터 추가 + ) + ) .orderBy(desc(rfqLastDetailsView.detailId)); - return { - success: true, + return { + success: true, data: { // RFQ 기본 정보 (rfqsLastView에서 제공) id: rfqData.id, @@ -1719,58 +1859,58 @@ export async function getRfqWithDetails(rfqId: number) { rfqTitle: rfqData.rfqTitle, series: rfqData.series, rfqSealedYn: rfqData.rfqSealedYn, - + // ITB 관련 projectCompany: rfqData.projectCompany, projectFlag: rfqData.projectFlag, projectSite: rfqData.projectSite, smCode: rfqData.smCode, - + // PR 정보 prNumber: rfqData.prNumber, prIssueDate: rfqData.prIssueDate, - + // 프로젝트 정보 projectId: rfqData.projectId, projectCode: rfqData.projectCode, projectName: rfqData.projectName, - + // 아이템 정보 itemCode: rfqData.itemCode, itemName: rfqData.itemName, - + // 패키지 정보 packageNo: rfqData.packageNo, packageName: rfqData.packageName, - + // 날짜 및 상태 dueDate: rfqData.dueDate, rfqSendDate: rfqData.rfqSendDate, status: rfqData.status, - + // PIC 정보 picId: rfqData.picId, picCode: rfqData.picCode, picName: rfqData.picName, picUserName: rfqData.picUserName, engPicName: rfqData.engPicName, - + // 집계 정보 (View에서 이미 계산됨) vendorCount: rfqData.vendorCount, shortListedVendorCount: rfqData.shortListedVendorCount, quotationReceivedCount: rfqData.quotationReceivedCount, prItemsCount: rfqData.prItemsCount, majorItemsCount: rfqData.majorItemsCount, - + // 견적 제출 정보 earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, - + // Major Item 정보 majorItemMaterialCode: rfqData.majorItemMaterialCode, majorItemMaterialDescription: rfqData.majorItemMaterialDescription, majorItemMaterialCategory: rfqData.majorItemMaterialCategory, majorItemPrNo: rfqData.majorItemPrNo, - + // 감사 정보 createdBy: rfqData.createdBy, createdByUserName: rfqData.createdByUserName, @@ -1780,20 +1920,20 @@ export async function getRfqWithDetails(rfqId: number) { updatedBy: rfqData.updatedBy, updatedByUserName: rfqData.updatedByUserName, updatedAt: rfqData.updatedAt, - + // 비고 remark: rfqData.remark, - + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) details: details.map(d => ({ detailId: d.detailId, - + // 벤더 정보 vendorId: d.vendorId, vendorName: d.vendorName, vendorCode: d.vendorCode, vendorCountry: d.vendorCountry, - + // 조건 정보 currency: d.currency, paymentTermsCode: d.paymentTermsCode, @@ -1806,7 +1946,7 @@ export async function getRfqWithDetails(rfqId: number) { taxCode: d.taxCode, placeOfShipping: d.placeOfShipping, placeOfDestination: d.placeOfDestination, - + // Boolean 필드들 shortList: d.shortList, returnYn: d.returnYn, @@ -1818,13 +1958,13 @@ export async function getRfqWithDetails(rfqId: number) { materialPriceRelatedYn: d.materialPriceRelatedYn, sparepartYn: d.sparepartYn, firstYn: d.firstYn, - + // 설명 필드 firstDescription: d.firstDescription, sparepartDescription: d.sparepartDescription, remark: d.remark, cancelReason: d.cancelReason, - + // 견적 관련 정보 (View에서 이미 계산됨) hasQuotation: d.hasQuotation, quotationStatus: d.quotationStatus, @@ -1833,11 +1973,18 @@ export async function getRfqWithDetails(rfqId: number) { quotationVersionCount: d.quotationVersionCount, lastQuotationDate: d.lastQuotationDate, quotationSubmittedAt: d.quotationSubmittedAt, - + // 감사 정보 updatedBy: d.updatedBy, updatedByUserName: d.updatedByUserName, updatedAt: d.updatedAt, + + sendVersion: d.sendVersion, + emailSentAt: d.emailSentAt, + emailSentTo: d.emailSentTo, + emailResentCount: d.emailResentCount, + lastEmailSentAt: d.lastEmailSentAt, + emailStatus: d.emailStatus, })), } }; @@ -1857,59 +2004,59 @@ export interface RfqFullInfo { rfqTitle: string | null; series: string | null; rfqSealedYn: boolean | null; - + // ITB 관련 projectCompany: string | null; projectFlag: string | null; projectSite: string | null; smCode: string | null; - + // RFQ 추가 필드 prNumber: string | null; prIssueDate: Date | null; - + // 프로젝트 정보 projectId: number | null; projectCode: string | null; projectName: string | null; - + // 아이템 정보 itemCode: string | null; itemName: string | null; - + // 패키지 정보 packageNo: string | null; packageName: string | null; - + // 날짜 정보 dueDate: Date | null; rfqSendDate: Date | null; - + // 상태 status: string; - + // 담당자 정보 picId: number | null; picCode: string | null; picName: string | null; picUserName: string | null; picTeam: string | null; - + // 설계담당자 engPicName: string | null; designTeam: string | null; - + // 자재그룹 정보 (PR Items에서) materialGroup: string | null; materialGroupDesc: string | null; - + // 카운트 정보 vendorCount: number; shortListedVendorCount: number; quotationReceivedCount: number; prItemsCount: number; majorItemsCount: number; - + // 감사 정보 createdBy: number; createdByUserName: string | null; @@ -1917,17 +2064,17 @@ export interface RfqFullInfo { updatedBy: number; updatedByUserName: string | null; updatedAt: Date; - + sentBy: number | null; sentByUserName: string | null; - + remark: string | null; - + // 평가 적용 여부 (추가 필드) evaluationApply?: boolean; quotationType?: string; contractType?: string; - + // 연관 데이터 vendors: VendorDetail[]; attachments: AttachmentInfo[]; @@ -1944,7 +2091,7 @@ export interface VendorDetail { vendorCategory?: string | null; vendorGrade?: string | null; basicContract?: string | null; - + // RFQ 조건 currency: string | null; paymentTermsCode: string | null; @@ -1957,32 +2104,32 @@ export interface VendorDetail { taxCode: string | null; placeOfShipping: string | null; placeOfDestination: string | null; - + // 상태 shortList: boolean; returnYn: boolean; returnedAt: Date | null; - + // GTC/NDA prjectGtcYn: boolean; generalGtcYn: boolean; ndaYn: boolean; agreementYn: boolean; - + // 추가 조건 materialPriceRelatedYn: boolean | null; sparepartYn: boolean | null; firstYn: boolean | null; firstDescription: string | null; sparepartDescription: string | null; - + remark: string | null; cancelReason: string | null; - + // 회신 상태 quotationStatus?: string | null; quotationSubmittedAt?: Date | null; - + // 업데이트 정보 updatedBy: number; updatedByUserName: string | null; @@ -1996,14 +2143,14 @@ export interface AttachmentInfo { serialNo: string; currentRevision: string; description: string | null; - + // 최신 리비전 정보 fileName: string | null; originalFileName: string | null; filePath: string | null; fileSize: number | null; fileType: string | null; - + createdBy: number; createdByUserName: string | null; createdAt: Date; @@ -2075,7 +2222,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { vendorCategory: v.vendor?.vendorCategory ?? null, vendorGrade: v.vendor?.vendorGrade ?? null, basicContract: v.vendor?.basicContract ?? null, - + currency: v.detail.currency, paymentTermsCode: v.detail.paymentTermsCode, paymentTermsDescription: v.paymentTerms?.description ?? null, @@ -2087,25 +2234,25 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { taxCode: v.detail.taxCode, placeOfShipping: v.detail.placeOfShipping, placeOfDestination: v.detail.placeOfDestination, - + shortList: v.detail.shortList, returnYn: v.detail.returnYn, returnedAt: v.detail.returnedAt, - + prjectGtcYn: v.detail.prjectGtcYn, generalGtcYn: v.detail.generalGtcYn, ndaYn: v.detail.ndaYn, agreementYn: v.detail.agreementYn, - + materialPriceRelatedYn: v.detail.materialPriceRelatedYn, sparepartYn: v.detail.sparepartYn, firstYn: v.detail.firstYn, firstDescription: v.detail.firstDescription, sparepartDescription: v.detail.sparepartDescription, - + remark: v.detail.remark, cancelReason: v.detail.cancelReason, - + updatedBy: v.detail.updatedBy, updatedByUserName: v.updatedByUser?.name ?? null, updatedAt: v.detail.updatedAt, @@ -2135,13 +2282,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { serialNo: a.attachment.serialNo, currentRevision: a.attachment.currentRevision, description: a.attachment.description, - + fileName: a.revision?.fileName ?? null, originalFileName: a.revision?.originalFileName ?? null, filePath: a.revision?.filePath ?? null, fileSize: a.revision?.fileSize ?? null, fileType: a.revision?.fileType ?? null, - + createdBy: a.attachment.createdBy, createdByUserName: a.createdByUser?.name ?? null, createdAt: a.attachment.createdAt, @@ -2152,13 +2299,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { const vendorCount = vendorDetails.length; const shortListedVendorCount = vendorDetails.filter(v => v.shortList).length; const quotationReceivedCount = vendorDetails.filter(v => v.quotationSubmittedAt).length; - + // PR Items 카운트 (별도 쿼리 필요) const prItemsCount = await db .select({ count: sql<number>`COUNT(*)` }) .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); - + const majorItemsCount = await db .select({ count: sql<number>`COUNT(*)` }) .from(rfqPrItems) @@ -2173,19 +2320,19 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { .from(users) .where(eq(users.id, rfq.createdBy)) .limit(1); - + const [updatedByUser] = await db .select({ name: users.name }) .from(users) .where(eq(users.id, rfq.updatedBy)) .limit(1); - - const [sentByUser] = rfq.sentBy + + const [sentByUser] = rfq.sentBy ? await db - .select({ name: users.name }) - .from(users) - .where(eq(users.id, rfq.sentBy)) - .limit(1) + .select({ name: users.name }) + .from(users) + .where(eq(users.id, rfq.sentBy)) + .limit(1) : [null]; // 7. 전체 정보 조합 @@ -2197,59 +2344,59 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { rfqTitle: rfq.rfqTitle, series: rfq.series, rfqSealedYn: rfq.rfqSealedYn, - + // ITB 관련 projectCompany: rfq.projectCompany, projectFlag: rfq.projectFlag, projectSite: rfq.projectSite, smCode: rfq.smCode, - + // RFQ 추가 필드 prNumber: rfq.prNumber, prIssueDate: rfq.prIssueDate, - + // 프로젝트 projectId: rfq.projectId, projectCode: null, // 프로젝트 조인 필요시 추가 projectName: null, // 프로젝트 조인 필요시 추가 - + // 아이템 itemCode: rfq.itemCode, itemName: rfq.itemName, - + // 패키지 packageNo: rfq.packageNo, packageName: rfq.packageName, - + // 날짜 dueDate: rfq.dueDate, rfqSendDate: rfq.rfqSendDate, - + // 상태 status: rfq.status, - + // 구매 담당자 picId: rfq.pic, picCode: rfq.picCode, picName: rfq.picName, picUserName: picUser?.name ?? null, picTeam: picUser?.department ?? null, // users 테이블에 department 필드가 있다고 가정 - + // 설계 담당자 engPicName: rfq.EngPicName, designTeam: null, // 추가 정보 필요시 입력 - + // 자재그룹 (PR Items에서) materialGroup: majorItem?.materialCategory ?? null, materialGroupDesc: majorItem?.materialDescription ?? null, - + // 카운트 vendorCount, shortListedVendorCount, quotationReceivedCount, prItemsCount: prItemsCount[0]?.count ?? 0, majorItemsCount: majorItemsCount[0]?.count ?? 0, - + // 감사 정보 createdBy: rfq.createdBy, createdByUserName: createdByUser?.name ?? null, @@ -2259,14 +2406,14 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { updatedAt: rfq.updatedAt, sentBy: rfq.sentBy, sentByUserName: sentByUser?.name ?? null, - + remark: rfq.remark, - + // 추가 필드 (필요시) evaluationApply: true, // 기본값 또는 별도 로직 quotationType: rfq.rfqType ?? undefined, contractType: undefined, // 별도 필드 필요 - + // 연관 데이터 vendors: vendorDetails, attachments: attachments, @@ -2284,7 +2431,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { */ export async function getRfqInfoForSend(rfqId: number) { const fullInfo = await getRfqFullInfo(rfqId); - + return { rfqCode: fullInfo.rfqCode, rfqTitle: fullInfo.rfqTitle || '', @@ -2369,6 +2516,7 @@ export interface VendorEmailInfo { contactEmails: string[]; // 영업/대표 담당자 이메일들 primaryEmail?: string | null; // 최종 선택된 주 이메일 currency?: string | null; + currency?: string | null; } /** @@ -2448,9 +2596,6 @@ export async function getRfqSendData(rfqId: number): Promise<RfqSendData> { packageNo: rfq.packageNo || undefined, packageName: rfq.packageName || undefined, designPicName: rfq.EngPicName || undefined, - rfqTitle: rfq.rfqTitle || undefined, - rfqType: rfq.rfqType || undefined, - designTeam: undefined, // 필요시 추가 조회 materialGroup: majorItem?.materialCategory || undefined, materialGroupDesc: majorItem?.materialDescription || undefined, dueDate: rfq.dueDate || new Date(), @@ -2515,7 +2660,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma // 3. 데이터 조합 const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => { const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id); - + // ContactDetail 형식으로 변환 const contacts: ContactDetail[] = vendorContacts.map(c => ({ id: c.id, @@ -2526,7 +2671,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma phone: c.contactPhone, isPrimary: c.isPrimary, })); - + // 포지션별로 그룹화 const contactsByPosition: Record<string, ContactDetail[]> = {}; contacts.forEach(contact => { @@ -2536,7 +2681,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma } contactsByPosition[position].push(contact); }); - + // 주 이메일 선택 우선순위: // 1. isPrimary가 true인 담당자 이메일 // 2. 대표자 이메일 @@ -2545,7 +2690,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma // 5. 첫번째 담당자 이메일 const primaryContact = contacts.find(c => c.isPrimary); const salesContact = contacts.find(c => c.position === '영업'); - const primaryEmail = + const primaryEmail = primaryContact?.email || vendor.representativeEmail || vendor.email || @@ -2584,17 +2729,23 @@ export async function getSelectedVendorsWithEmails( try { // 1. 벤더 이메일 정보 조회 const vendorEmailInfos = await getVendorEmailInfo(vendorIds); - + // 2. RFQ Detail에서 통화 정보 조회 (옵션) const rfqDetailsData = await db .select({ vendorId: rfqLastDetails.vendorsId, currency: rfqLastDetails.currency, + ndaYn: rfqLastDetails.ndaYn, + generalGtcYn: rfqLastDetails.generalGtcYn, + projectGtcYn: rfqLastDetails.projectGtcYn, + agreementYn: rfqLastDetails.agreementYn, + sendVersion: rfqLastDetails.sendVersion, }) .from(rfqLastDetails) .where( and( eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true), sql`${rfqLastDetails.vendorsId} IN ${vendorIds}` ) ); @@ -2605,6 +2756,11 @@ export async function getSelectedVendorsWithEmails( return { ...vendor, currency: detail?.currency || vendor.currency || 'KRW', + ndaYn: detail?.ndaYn, + generalGtcYn: detail?.generalGtcYn, + projectGtcYn: detail?.projectGtcYn, + agreementYn: detail?.agreementYn, + sendVersion: detail?.sendVersion }; }); @@ -2613,4 +2769,977 @@ export async function getSelectedVendorsWithEmails( console.error("선택된 벤더 정보 조회 실패:", error); throw error; } +} + +interface SendRfqVendor { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails: Array<{ id: string; email: string; name?: string }>; +} + +export interface ContractRequirements { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; // Project GTC를 위한 프로젝트 코드 +} + +export interface VendorForSend { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; + currency?: string | null; + + // 기본계약 관련 + contractRequirements?: ContractRequirements; + + // 재발송 관련 + isResend: boolean; + sendVersion?: number; +} + +export interface SendRfqParams { + rfqId: number; + rfqCode?: string; + vendors: VendorForSend[]; + attachmentIds: number[]; + message?: string; +} + +export async function sendRfqToVendors({ + rfqId, + rfqCode, + vendors, + attachmentIds, + message, + generatedPdfs +}: SendRfqParams & { + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const currentUser = session.user + + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. RFQ 정보 조회 + const [rfqData] = await tx + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + rfqType: rfqsLast.rfqType, + rfqTitle: rfqsLast.rfqTitle, + projectId: rfqsLast.projectId, + itemCode: rfqsLast.itemCode, + itemName: rfqsLast.itemName, + dueDate: rfqsLast.dueDate, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + picId: rfqsLast.pic, + picCode: rfqsLast.picCode, + picName: rfqsLast.picName, + projectCompany: rfqsLast.projectCompany, + projectFlag: rfqsLast.projectFlag, + projectSite: rfqsLast.projectSite, + smCode: rfqsLast.smCode, + prNumber: rfqsLast.prNumber, + prIssueDate: rfqsLast.prIssueDate, + series: rfqsLast.series, + EngPicName: rfqsLast.EngPicName, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)); + + if (!rfqData) { + throw new Error("RFQ 정보를 찾을 수 없습니다."); + } + + // 2. PIC 사용자 정보 조회 + let picEmail = process.env.Email_From_Address; + let picName = rfqData.picName || "구매담당자"; + + if (rfqData.picId) { + const [picUser] = await tx + .select() + .from(users) + .where(eq(users.id, rfqData.picId)); + + if (picUser?.email) { + picEmail = picUser.email; + picName = picUser.name || picName; + } + } + + // 3. 프로젝트 정보 조회 + let projectInfo = null; + if (rfqData.projectId) { + const [project] = await tx + .select() + .from(projects) + .where(eq(projects.id, rfqData.projectId)); + projectInfo = project; + } + + // 4. PR 아이템 정보 조회 + const prItems = await tx + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, rfqId)); + + // 5. 첨부파일 정보 조회 및 준비 + const attachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + attachmentIds.length > 0 + ? sql`${rfqLastAttachments.id} IN (${sql.join(attachmentIds, sql`, `)})` + : sql`1=1` + ) + ); + + // 6. 이메일 첨부파일 준비 + const emailAttachments = []; + for (const { attachment, revision } of attachments) { + if (revision?.filePath) { + try { + const fullPath = path.join(process.cwd(), `${process.env.NAS_PATH}`, revision.filePath); + const fileBuffer = await fs.readFile(fullPath); + emailAttachments.push({ + filename: revision.originalFileName, + content: fileBuffer, + contentType: revision.fileType || 'application/octet-stream' + }); + } catch (error) { + console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error); + } + } + } + + // ========== TBE용 설계 문서 조회 (중요!) ========== + const designAttachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + eq(rfqLastAttachments.attachmentType, "설계") // 설계 문서만 필터링 + ) + ); + + + // 7. 각 벤더별 처리 + const results = []; + const errors = []; + const savedContracts = []; + const tbeSessionsCreated = []; + + const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated"); + await mkdir(contractsDir, { recursive: true }); + + + for (const vendor of vendors) { + // 재발송 여부 확인 + const isResend = vendor.isResend || false; + const sendVersion = (vendor.sendVersion || 0) + 1; + + // 7.4 이메일 수신자 정보 준비 + const toEmails = [vendor.selectedMainEmail]; + const ccEmails = [...vendor.additionalEmails]; + + vendor.customEmails?.forEach(custom => { + if (custom.email !== vendor.selectedMainEmail && + !vendor.additionalEmails.includes(custom.email)) { + ccEmails.push(custom.email); + } + }); + + // 이메일 수신자 정보를 JSON으로 저장 + const emailRecipients = { + to: toEmails, + cc: ccEmails, + sentBy: picEmail + }; + + try { + // 7.1 rfqLastDetails 조회 또는 생성 + let [rfqDetail] = await tx + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendor.vendorId), + eq(rfqLastDetails.isLatest, true), + ) + ); + + if (!rfqDetail) { + throw new Error("해당 RFQ에는 벤더가 이미 할당되어있는 상태이어야합니다."); + } + + // 기존 rfqDetail을 isLatest=false로 업데이트 + const updateResult = await tx + .update(rfqLastDetails) + .set({ + isLatest: false, + updatedAt: new Date() // 업데이트 시간도 기록 + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendor.vendorId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning({ id: rfqLastDetails.id }); + + console.log(`Updated ${updateResult.length} records to isLatest=false for vendor ${vendor.vendorId}`); + + const { id, updatedBy, updatedAt, isLatest, sendVersion: oldSendVersion, emailResentCount, ...restRfqDetail } = rfqDetail; + + + let [newRfqDetail] = await tx + .insert(rfqLastDetails) + .values({ + ...restRfqDetail, // 기존 값들 복사 + + // 업데이트할 필드들 + updatedBy: Number(currentUser.id), + updatedAt: new Date(), + isLatest: true, + + // 이메일 관련 필드 업데이트 + emailSentAt: new Date(), + emailSentTo: JSON.stringify(emailRecipients), + emailResentCount: isResend ? (emailResentCount || 0) + 1 : 1, + sendVersion: sendVersion, + lastEmailSentAt: new Date(), + emailStatus: "sent", + + agreementYn: vendor.contractRequirements.agreementYn || false, + ndaYn: vendor.contractRequirements.ndaYn || false, + projectGtcYn: vendor.contractRequirements.projectGtcYn || false, + generalGtcYn: vendor.contractRequirements.generalGtcYn || false, + + + }) + .returning(); + + + // 생성된 PDF 저장 및 DB 기록 + if (generatedPdfs && vendor.contractRequirements) { + const vendorPdfs = generatedPdfs.filter(pdf => + pdf.key.startsWith(`${vendor.vendorId}_`) + ); + + for (const pdfData of vendorPdfs) { + console.log(vendor.vendorId, pdfData.buffer.length) + // PDF 파일 저장 + const pdfBuffer = Buffer.from(pdfData.buffer); + const fileName = pdfData.fileName; + const filePath = path.join(contractsDir, fileName); + + await writeFile(filePath, pdfBuffer); + + const templateName = pdfData.key.split('_')[2]; + + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, `%${templateName}%`), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + console.log("템플릿", templateName, template) + + // 기존 계약이 있는지 확인 + const [existingContract] = await tx + .select() + .from(basicContract) + .where( + and( + eq(basicContract.templateId, template.id), + eq(basicContract.vendorId, vendor.vendorId), + eq(basicContract.rfqCompanyId, newRfqDetail.id) + ) + ) + .limit(1); + + let contractRecord; + + if (existingContract) { + // 기존 계약이 있으면 업데이트 + [contractRecord] = await tx + .update(basicContract) + .set({ + requestedBy: Number(currentUser.id), + status: "PENDING", // 재발송 상태 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: addDays(new Date(), 10), + updatedAt: new Date(), + // version을 증가시키거나 이력 관리가 필요하면 추가 + }) + .where(eq(basicContract.id, existingContract.id)) + .returning(); + + console.log("기존 계약 업데이트:", contractRecord.id) + } else { + // 새 계약 생성 + [contractRecord] = await tx + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendor.vendorId, + rfqCompanyId: newRfqDetail.id, + requestedBy: Number(currentUser.id), + status: "PENDING", + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: addDays(new Date(), 10), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log("새 계약 생성:", contractRecord.id) + } + + console.log(contractRecord.vendorId, contractRecord.filePath) + + savedContracts.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + templateName: templateName, + contractId: contractRecord.id, + fileName: fileName, + isUpdated: !!existingContract // 업데이트 여부 표시 + }); + } + } + + // 7.3 기존 응답 레코드 확인 + const existingResponses = await tx + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendor.vendorId) + ) + ); + + // 7.4 기존 응답이 있으면 isLatest=false로 업데이트 + if (existingResponses.length > 0) { + await tx + .update(rfqLastVendorResponses) + .set({ + isLatest: false + }) + .where( + and( + eq(rfqLastVendorResponses.vendorId, vendor.vendorId), + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + ) + ) + } + + // 7.5 새로운 응답 레코드 생성 + const newResponseVersion = existingResponses.length > 0 + ? Math.max(...existingResponses.map(r => r.responseVersion)) + 1 + : 1; + + const [vendorResponse] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: newRfqDetail.id, + vendorId: vendor.vendorId, + responseVersion: newResponseVersion, + isLatest: true, + status: "초대됨", + currency: rfqDetail.currency || "USD", + + // 감사 필드 + createdBy: currentUser.id, + updatedBy: currentUser.id, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + + // ========== TBE 세션 및 문서 검토 레코드 생성 시작 ========== + // 7.4 기존 활성 TBE 세션 확인 + const [existingActiveTbe] = await tx + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfqId), + eq(rfqLastTbeSessions.vendorId, vendor.vendorId), + sql`${rfqLastTbeSessions.status} IN ('준비중', '진행중', '검토중', '보류')` + ) + ); + + // 7.5 활성 TBE 세션이 없는 경우에만 새로 생성 + if (!existingActiveTbe) { + // TBE 세션 코드 생성 + const year = new Date().getFullYear(); + const [lastTbeSession] = await tx + .select({ sessionCode: rfqLastTbeSessions.sessionCode }) + .from(rfqLastTbeSessions) + .where(sql`${rfqLastTbeSessions.sessionCode} LIKE 'TBE-${year}-%'`) + .orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`) + .limit(1); + + let sessionNumber = 1; + if (lastTbeSession?.sessionCode) { + const lastNumber = parseInt(lastTbeSession.sessionCode.split('-')[2]); + sessionNumber = isNaN(lastNumber) ? 1 : lastNumber + 1; + } + + const sessionCode = `TBE-${year}-${String(sessionNumber).padStart(3, '0')}`; + + // TBE 세션 생성 + const [tbeSession] = await tx + .insert(rfqLastTbeSessions) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: newRfqDetail.id, + vendorId: vendor.vendorId, + sessionCode: sessionCode, + sessionTitle: `${rfqData.rfqCode} - ${vendor.vendorName} 기술검토`, + sessionType: "initial", + status: "준비중", + evaluationResult: null, + plannedStartDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 1) : addDays(new Date(), 14), + plannedEndDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 7) : addDays(new Date(), 21), + leadEvaluatorId: rfqData.picId, + createdBy: Number(currentUser.id), + updatedBy: Number(currentUser.id), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log(`TBE 세션 생성됨: ${sessionCode} for vendor ${vendor.vendorName}`); + + // ========== 설계 문서에 대한 검토 레코드 생성 (중요!) ========== + const documentReviewsCreated = []; + + for (const { attachment, revision } of designAttachments) { + const [documentReview] = await tx + .insert(rfqLastTbeDocumentReviews) + .values({ + tbeSessionId: tbeSession.id, + documentSource: "buyer", + buyerAttachmentId: attachment.id, + buyerAttachmentRevisionId: revision?.id || null, + vendorAttachmentId: null, // 구매자 문서이므로 null + documentType: attachment.attachmentType, + documentName: revision?.originalFileName || attachment.serialNo, + reviewStatus: "미검토", + technicalCompliance: null, + qualityAcceptable: null, + requiresRevision: false, + reviewComments: null, + revisionRequirements: null, + hasPdftronComments: false, + pdftronDocumentId: null, + pdftronAnnotationCount: 0, + reviewedBy: null, + reviewedAt: null, + additionalReviewers: null, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + documentReviewsCreated.push({ + reviewId: documentReview.id, + attachmentId: attachment.id, + documentName: documentReview.documentName + }); + + console.log(`문서 검토 레코드 생성: ${documentReview.documentName}`); + } + + tbeSessionsCreated.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + sessionId: tbeSession.id, + sessionCode: tbeSession.sessionCode, + documentReviewsCount: documentReviewsCreated.length + }); + + console.log(`TBE 세션 ${sessionCode}: 총 ${documentReviewsCreated.length}개 문서 검토 레코드 생성`); + } else { + console.log(`TBE 세션이 이미 존재함: vendor ${vendor.vendorName}`); + } + // ========== TBE 세션 및 문서 검토 레코드 생성 끝 ========== + + } + + // 8. RFQ 상태 업데이트 + if (results.length > 0) { + await tx + .update(rfqsLast) + .set({ + status: "RFQ 발송", + rfqSendDate: new Date(), + sentBy: Number(currentUser.id), + updatedBy: Number(currentUser.id), + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + } + + return { + success: true, + results, + errors, + savedContracts, + totalSent: results.length, + totalFailed: errors.length, + totalContracts: savedContracts.length + }; + }); + + return result; + + } catch (error) { + console.error("RFQ 발송 실패:", error); + throw new Error( + error instanceof Error + ? error.message + : "RFQ 발송 중 오류가 발생했습니다." + ); + } +} + +export async function updateRfqDueDate( + rfqId: number, + newDueDate: Date | string, + rfqCode: string, + rfqTitle: string +) { + try { + // ✅ 날짜 정규화 - 문자열을 Date 객체로 변환 + let normalizedDate: Date; + + if (typeof newDueDate === 'string') { + // ISO 문자열인 경우 (2024-01-15T14:30:00.000Z) + if (newDueDate.includes('T')) { + normalizedDate = new Date(newDueDate); + } + // YYYY-MM-DD HH:mm 형식인 경우 + else if (newDueDate.includes(' ') && newDueDate.includes(':')) { + normalizedDate = new Date(newDueDate); + } + // YYYY-MM-DD 형식인 경우 - 한국 시간 기준으로 설정 + else if (/^\d{4}-\d{2}-\d{2}$/.test(newDueDate)) { + normalizedDate = new Date(`${newDueDate}T00:00:00+09:00`); + } + else { + normalizedDate = new Date(newDueDate); + } + } else if (newDueDate instanceof Date) { + normalizedDate = newDueDate; + } else { + // Date 객체가 아닌 경우 처리 + normalizedDate = new Date(newDueDate as any); + } + + // 유효한 날짜인지 확인 + if (isNaN(normalizedDate.getTime())) { + return { + success: false, + message: "유효하지 않은 날짜 형식입니다.", + } + } + + // 1. RFQ 정보 조회 + const rfqData = await db + .select() + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1) + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + message: "RFQ를 찾을 수 없습니다.", + } + } + + const rfq = rfqData[0] + const oldDueDate = rfq.dueDate + + // 2. Due Date 업데이트 - normalizedDate 사용 + await db + .update(rfqsLast) + .set({ + dueDate: normalizedDate, + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, rfqId)) + + // 3. 프로젝트 정보 조회 (있는 경우) + let projectInfo = null + if (rfq.projectId) { + const projectData = await db + .select() + .from(projects) + .where(eq(projects.id, rfq.projectId)) + .limit(1) + + if (projectData && projectData.length > 0) { + projectInfo = projectData[0] + } + } + + // 4. PIC 정보 조회 + let picInfo = null + if (rfq.pic) { + const picData = await db + .select() + .from(users) + .where(eq(users.id, rfq.pic)) + .limit(1) + + if (picData && picData.length > 0) { + picInfo = picData[0] + } + } + + const picName = picInfo?.name || rfq.picName || "구매팀" + const picEmail = picInfo?.email || process.env.Email_From_Address || "procurement@company.com" + + // 5. RFQ Details에서 이메일 수신자 조회 + const rfqDetailsData = await db + .select({ + emailSentTo: rfqLastDetails.emailSentTo, + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + + if (rfqDetailsData.length === 0) { + // 페이지 재검증 + revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, 'layout') + + return { + success: true, + message: "마감일이 수정되었습니다. (발송된 이메일이 없음)", + } + } + + // 6. 각 vendor별로 이메일 발송 + const emailPromises = [] + + for (const detail of rfqDetailsData) { + if (!detail.emailSentTo) continue + + // vendor 정보 조회 + let vendorInfo = null + if (detail.vendorId) { + const vendorData = await db + .select() + .from(vendors) + .where(eq(vendors.id, detail.vendorId)) + .limit(1) + + if (vendorData && vendorData.length > 0) { + vendorInfo = vendorData[0] + } + } + + // 이메일 언어 결정 (vendor의 country가 KR이면 한국어, 아니면 영어) + const isKorean = vendorInfo?.country === 'KR' + const language = isKorean ? 'ko' : 'en' + const locale = isKorean ? ko : enUS + + const emailSubject = isKorean + ? `[마감일 변경] ${rfqCode} ${rfqTitle || ''}`.trim() + : `[Due Date Changed] ${rfqCode} ${rfqTitle || ''}`.trim() + + // ✅ 날짜 포맷팅 - 시간 포함하도록 수정 + const oldDateFormatted = (() => { + try { + if (!oldDueDate) { + return isKorean ? "미설정" : "Not set"; + } + const date = new Date(oldDueDate); + // 시간까지 포함한 포맷 + return format(date, "PPP HH:mm", { locale }); + } catch { + return isKorean ? "미설정" : "Not set"; + } + })(); + + const newDateFormatted = (() => { + try { + // normalizedDate 사용 + return format(normalizedDate, "PPP HH:mm", { locale }); + } catch (error) { + console.error("Date formatting error:", error); + return normalizedDate.toISOString(); + } + })(); + + // 이메일 발송 - null/undefined 값 처리 + const emailContext: Record<string, unknown> = { + language: language ?? (isKorean ? "ko" : "en"), + vendorName: vendorInfo?.vendorName ?? "", + rfqCode: rfqCode ?? "", + rfqTitle: rfqTitle ?? "", + rfqType: rfq.rfqType ?? "", + projectCode: projectInfo?.code ?? "", + projectName: projectInfo?.name ?? "", + packageNo: rfq.packageNo ?? "", + packageName: rfq.packageName ?? "", + itemCode: rfq.itemCode ?? "", + itemName: rfq.itemName ?? "", + oldDueDate: oldDateFormatted, + newDueDate: newDateFormatted, + picName: picName ?? "구매팀", + picEmail: picEmail ?? (process.env.Email_From_Address ?? "procurement@company.com"), + engPicName: rfq.EngPicName ?? "", + portalUrl: + (process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/partners/rfq-last/${rfqId}` + : `https://partners.sevcp.com/partners/rfq-last/${rfqId}`), + }; + + const validContext = Object.fromEntries( + Object.entries(emailContext ?? {}).filter(([, value]) => value !== null && value !== undefined) + ); + + let toEmails: string[] = []; + let ccEmails: string[] = []; + + try { + const emailData = typeof detail.emailSentTo === 'string' + ? JSON.parse(detail.emailSentTo) + : detail.emailSentTo; + + if (emailData.to) { + toEmails = Array.isArray(emailData.to) + ? emailData.to.filter(Boolean) + : [emailData.to].filter(Boolean); + } + + if (emailData.cc) { + ccEmails = Array.isArray(emailData.cc) + ? emailData.cc.filter(Boolean) + : [emailData.cc].filter(Boolean); + } + } catch (error) { + console.warn("Failed to parse emailSentTo as JSON, trying comma-separated:", error); + toEmails = (detail.emailSentTo ?? "") + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + } + + console.log("Parsed emails - To:", toEmails, "CC:", ccEmails); + + if (toEmails.length === 0) { + continue; + } + + emailPromises.push( + sendEmail({ + from: isDevelopment + ? (process.env.Email_From_Address ?? "no-reply@company.com") + : `"${picName}" <${picEmail}>`, + to: toEmails.join(", "), + cc: ccEmails.length > 0 ? ccEmails.join(", ") : undefined, + subject: emailSubject, + template: "rfq-due-date-change", + context: validContext, + }) + ); + } + + // 모든 이메일 발송 + if (emailPromises.length > 0) { + await Promise.allSettled(emailPromises) + } + + try { + await revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, "layout"); + } catch (e) { + console.warn("revalidatePath failed:", e); + } + + // ✅ 성공 메시지도 시간 포함하도록 수정 + return { + success: true, + message: `마감일이 ${format(normalizedDate, "yyyy년 MM월 dd일 HH시 mm분", { locale: ko })}로 수정되었으며, 관련 업체에 이메일이 발송되었습니다.`, + } + } catch (error) { + console.error("Error updating due date:", error) + return { + success: false, + message: "마감일 수정 중 오류가 발생했습니다.", + } + } +} + + +export async function deleteRfqVendor({ + rfqId, + detailId, + vendorId, +}: { + rfqId: number; + detailId: number; + vendorId: number; +}): Promise<{ + success: boolean; + message?: string; +}> { + try { + const deleteResult = await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.id, detailId), + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .returning({ id: rfqLastDetails.id }); + + if (deleteResult.length === 0) { + throw new Error("삭제할 벤더를 찾을 수 없습니다."); + } + + // 캐시 무효화 + revalidatePath(`/partners/rfq-last/${rfqId}`); + + return { + success: true, + message: "벤더가 성공적으로 삭제되었습니다.", + }; + } catch (error) { + console.error("벤더 삭제 오류:", error); + + return { + success: false, + message: error instanceof Error ? error.message : "벤더 삭제 중 오류가 발생했습니다.", + }; + } +} + +export async function updateVendorContractRequirements({ + rfqId, + detailId, + contractRequirements, +}: UpdateVendorContractRequirementsParams): Promise<UpdateResult> { + try { + // gtcType에 따라 generalGtcYn과 projectGtcYn 설정 + const generalGtcYn = contractRequirements.gtcType === "general"; + const projectGtcYn = contractRequirements.gtcType === "project"; + + // 데이터베이스 업데이트 + const result = await db + .update(rfqLastDetails) + .set({ + agreementYn: contractRequirements.agreementYn, + ndaYn: contractRequirements.ndaYn, + gtcType: contractRequirements.gtcType, + generalGtcYn, + projectGtcYn, + updatedAt: new Date(), + // updatedBy는 세션에서 가져와야 하는 경우 추가 + // updatedBy: getCurrentUserId(), + }) + .where(eq(rfqLastDetails.id, detailId)) + .returning(); + + // 결과 검증 + if (!result || result.length === 0) { + return { + success: false, + error: "업체 정보를 찾을 수 없습니다.", + }; + } + + // // 캐시 재검증 (필요한 경우) + // revalidatePath(`/rfq/${rfqId}`); + // revalidatePath(`/rfq/${rfqId}/vendors`); + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error("Error updating vendor contract requirements:", error); + + return { + success: false, + error: error instanceof Error ? error.message : "업데이트 중 오류가 발생했습니다.", + }; + } +} + +// 헬퍼 함수 +function getTemplateNameByType( + contractType: string, + requirements: any +): string { + switch (contractType) { + case "NDA": return "비밀"; + case "General_GTC": return "General GTC"; + case "Project_GTC": return requirements.projectCode || "Project GTC"; + case "기술자료": return "기술"; + default: return contractType; + } }
\ No newline at end of file |
