diff options
Diffstat (limited to 'lib/rfq-last/compare-action.ts')
| -rw-r--r-- | lib/rfq-last/compare-action.ts | 477 |
1 files changed, 324 insertions, 153 deletions
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts index 2be594e9..1a50a373 100644 --- a/lib/rfq-last/compare-action.ts +++ b/lib/rfq-last/compare-action.ts @@ -1,7 +1,7 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray,ne } from "drizzle-orm"; +import { eq, and, inArray, ne, asc, desc } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, @@ -10,7 +10,9 @@ import { rfqLastVendorQuotationItems, vendors, paymentTerms, - incoterms,vendorSelections + incoterms, + vendorSelections, + users } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth/next" @@ -44,14 +46,10 @@ export interface ComparisonData { }; } -export interface VendorComparison { - vendorId: number; - vendorName: string; - vendorCode: string; - vendorCountry?: string; - - // 응답 정보 +// 벤더의 각 차수별 응답 정보 +export interface VendorResponseVersion { responseId: number; + responseVersion: number; participationStatus: string; responseStatus: string; submittedAt: Date | null; @@ -62,7 +60,58 @@ export interface VendorComparison { rank?: number; priceVariance?: number; - // 구매자 제시 조건 + // 벤더 제안 조건 + vendorConditions: { + currency?: string; + paymentTermsCode?: string; + paymentTermsDesc?: string; + incotermsCode?: string; + incotermsDesc?: string; + deliveryDate?: Date | null; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + firstAcceptance?: "수용" | "부분수용" | "거부"; + firstDescription?: string; + sparepartAcceptance?: "수용" | "부분수용" | "거부"; + sparepartDescription?: string; + materialPriceRelatedYn?: boolean; + materialPriceRelatedReason?: string; + }; + + // 조건 차이 분석 + conditionDifferences: { + hasDifferences: boolean; + differences: string[]; + criticalDifferences: string[]; + }; + + // 비고 + generalRemark?: string; + technicalProposal?: string; + + // 품목별 견적 아이템 정보 추가 + quotationItems?: { + prItemId: number; + unitPrice: number; + totalPrice: number; + currency: string; + quantity: number; + deliveryDate?: Date | null; + leadTime?: number; + manufacturer?: string; + modelNo?: string; + }[]; +} + +export interface VendorComparison { + vendorId: number; + vendorName: string; + vendorCode: string; + vendorCountry?: string; + + // 구매자 제시 조건 (모든 차수에 공통) buyerConditions: { currency: string; paymentTermsCode: string; @@ -81,7 +130,25 @@ export interface VendorComparison { materialPriceRelatedYn: boolean; }; - // 벤더 제안 조건 + // 차수별 응답 배열 (최신 순) + responses: VendorResponseVersion[]; + + // 최신 응답 정보 (편의성) + latestResponse?: VendorResponseVersion; + + // 응답 정보 (레거시, 하위 호환) + responseId: number; + participationStatus: string; + responseStatus: string; + submittedAt: Date | null; + + // 가격 정보 (최신 응답 기준, 레거시 호환) + totalAmount: number; + currency: string; + rank?: number; + priceVariance?: number; + + // 레거시 호환: 최신 응답의 조건 정보 vendorConditions: { currency?: string; paymentTermsCode?: string; @@ -101,14 +168,14 @@ export interface VendorComparison { materialPriceRelatedReason?: string; }; - // 조건 차이 분석 + // 레거시 호환: 최신 응답의 조건 차이 분석 conditionDifferences: { hasDifferences: boolean; differences: string[]; criticalDifferences: string[]; }; - // 비고 + // 레거시 호환: 최신 응답의 비고 generalRemark?: string; technicalProposal?: string; @@ -188,7 +255,7 @@ export async function getComparisonData( if (!rfqData[0]) return null; - // 2. 벤더별 정보, 응답, 선정 정보 조회 + // 2a. 벤더 기본 정보 + 구매자 조건 + 선정 정보 조회 const vendorData = await db .select({ // 벤더 정보 @@ -228,14 +295,30 @@ export async function getComparisonData( contractStatus: rfqLastDetails.contractStatus, contractNo: rfqLastDetails.contractNo, contractCreatedAt: rfqLastDetails.contractCreatedAt, - - // 벤더 응답 + }) + .from(vendors) + .innerJoin( + rfqLastDetails, + and( + eq(rfqLastDetails.vendorsId, vendors.id), + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .where(inArray(vendors.id, vendorIds)); + + // 2b. 모든 차수의 벤더 응답 조회 (isLatest 조건 제거) + const allVendorResponses = await db + .select({ responseId: rfqLastVendorResponses.id, + vendorId: rfqLastVendorResponses.vendorId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, participationStatus: rfqLastVendorResponses.participationStatus, responseStatus: rfqLastVendorResponses.status, submittedAt: rfqLastVendorResponses.submittedAt, totalAmount: rfqLastVendorResponses.totalAmount, - responseCurrency: rfqLastVendorResponses.currency, + currency: rfqLastVendorResponses.currency, // 벤더 제안 조건 vendorCurrency: rfqLastVendorResponses.vendorCurrency, @@ -260,24 +343,14 @@ export async function getComparisonData( generalRemark: rfqLastVendorResponses.generalRemark, technicalProposal: rfqLastVendorResponses.technicalProposal, }) - .from(vendors) - .innerJoin( - rfqLastDetails, - and( - eq(rfqLastDetails.vendorsId, vendors.id), - eq(rfqLastDetails.rfqsLastId, rfqId), - eq(rfqLastDetails.isLatest, true) - ) - ) - .leftJoin( - rfqLastVendorResponses, + .from(rfqLastVendorResponses) + .where( and( - eq(rfqLastVendorResponses.vendorId, vendors.id), eq(rfqLastVendorResponses.rfqsLastId, rfqId), - eq(rfqLastVendorResponses.isLatest, true) + inArray(rfqLastVendorResponses.vendorId, vendorIds) ) ) - .where(inArray(vendors.id, vendorIds)); + .orderBy(asc(rfqLastVendorResponses.vendorId), asc(rfqLastVendorResponses.responseVersion)); // 3. 선정자 이름 조회 (선정된 업체가 있는 경우) const selectedVendor = vendorData.find(v => v.isSelected); @@ -328,90 +401,165 @@ export async function getComparisonData( .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); - // 6. 벤더별 견적 아이템 조회 - const quotationItems = await db - .select({ - vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, - prItemId: rfqLastVendorQuotationItems.rfqPrItemId, - unitPrice: rfqLastVendorQuotationItems.unitPrice, - totalPrice: rfqLastVendorQuotationItems.totalPrice, - currency: rfqLastVendorQuotationItems.currency, - quantity: rfqLastVendorQuotationItems.quantity, - deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, - leadTime: rfqLastVendorQuotationItems.leadTime, - manufacturer: rfqLastVendorQuotationItems.manufacturer, - modelNo: rfqLastVendorQuotationItems.modelNo, - technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, - alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, - itemRemark: rfqLastVendorQuotationItems.itemRemark, - }) - .from(rfqLastVendorQuotationItems) - .where( - inArray( - rfqLastVendorQuotationItems.vendorResponseId, - vendorData.map(v => v.responseId).filter(id => id != null) - ) - ); - - // 7. 데이터 가공 및 분석 - const validAmounts = vendorData - .map(v => v.totalAmount) - .filter(a => a != null && a > 0); - - const minAmount = Math.min(...validAmounts); - const maxAmount = Math.max(...validAmounts); - const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length; - - // 8. 벤더별 비교 데이터 구성 - const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => { - const differences: string[] = []; - const criticalDifferences: string[] = []; - - // 조건 차이 분석 - if (v.vendorCurrency && v.vendorCurrency !== v.buyerCurrency) { - criticalDifferences.push(`통화: ${v.buyerCurrency} → ${v.vendorCurrency}`); - } - - if (v.vendorPaymentTermsCode && v.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) { - differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${v.vendorPaymentTermsCode}`); - } - - if (v.vendorIncotermsCode && v.vendorIncotermsCode !== v.buyerIncotermsCode) { - differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${v.vendorIncotermsCode}`); + // 6. 벤더별 견적 아이템 조회 (모든 응답 버전 포함) + const allResponseIds = allVendorResponses.map(r => r.responseId); + const quotationItems = allResponseIds.length > 0 + ? await db + .select({ + vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, + prItemId: rfqLastVendorQuotationItems.rfqPrItemId, + unitPrice: rfqLastVendorQuotationItems.unitPrice, + totalPrice: rfqLastVendorQuotationItems.totalPrice, + currency: rfqLastVendorQuotationItems.currency, + quantity: rfqLastVendorQuotationItems.quantity, + deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, + leadTime: rfqLastVendorQuotationItems.leadTime, + manufacturer: rfqLastVendorQuotationItems.manufacturer, + modelNo: rfqLastVendorQuotationItems.modelNo, + technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, + alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, + itemRemark: rfqLastVendorQuotationItems.itemRemark, + }) + .from(rfqLastVendorQuotationItems) + .where(inArray(rfqLastVendorQuotationItems.vendorResponseId, allResponseIds)) + : []; + + // 7. 데이터 가공 및 분석 - 각 벤더별 최신 차수 기준으로 평균 계산 + // 각 벤더별로 가장 높은 responseVersion을 가진 응답 찾기 + const latestResponsesByVendor = new Map<number, typeof allVendorResponses[0]>(); + allVendorResponses.forEach(response => { + const existing = latestResponsesByVendor.get(response.vendorId); + if (!existing || response.responseVersion > existing.responseVersion) { + latestResponsesByVendor.set(response.vendorId, response); } + }); + + const latestResponses = Array.from(latestResponsesByVendor.values()); + const validAmounts = latestResponses + .map(r => r.totalAmount) + .filter((a): a is number => a != null && a > 0); + + console.log("Latest responses:", latestResponses.map(r => ({ + vendorId: r.vendorId, + version: r.responseVersion, + amount: r.totalAmount, + isLatest: r.isLatest + }))); + console.log("Valid amounts for average:", validAmounts); + + const minAmount = validAmounts.length > 0 ? Math.min(...validAmounts) : 0; + const maxAmount = validAmounts.length > 0 ? Math.max(...validAmounts) : 0; + const avgAmount = validAmounts.length > 0 + ? validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length + : 0; + + // 8. 벤더별 비교 데이터 구성 (차수별 응답 포함) + const vendorComparisons: VendorComparison[] = vendorData.map((v) => { + // 이 벤더의 모든 응답 가져오기 + const vendorResponses = allVendorResponses.filter(r => r.vendorId === v.vendorId); - if (v.vendorDeliveryDate && v.buyerDeliveryDate) { - const buyerDate = new Date(v.buyerDeliveryDate); - const vendorDate = new Date(v.vendorDeliveryDate); - if (vendorDate > buyerDate) { - criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`); + // 차수별 응답 정보 구성 + const responses: VendorResponseVersion[] = vendorResponses.map(resp => { + const differences: string[] = []; + const criticalDifferences: string[] = []; + + // 조건 차이 분석 + if (resp.vendorCurrency && resp.vendorCurrency !== v.buyerCurrency) { + criticalDifferences.push(`통화: ${v.buyerCurrency} → ${resp.vendorCurrency}`); + } + + if (resp.vendorPaymentTermsCode && resp.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) { + differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${resp.vendorPaymentTermsCode}`); + } + + if (resp.vendorIncotermsCode && resp.vendorIncotermsCode !== v.buyerIncotermsCode) { + differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${resp.vendorIncotermsCode}`); + } + + if (resp.vendorDeliveryDate && v.buyerDeliveryDate) { + const buyerDate = new Date(v.buyerDeliveryDate); + const vendorDate = new Date(resp.vendorDeliveryDate); + if (vendorDate > buyerDate) { + criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`); + } } - } - if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) { - criticalDifferences.push("초도품 거부"); - } - - if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) { - criticalDifferences.push("스페어파트 거부"); - } + if (resp.vendorFirstAcceptance === "거부" && v.buyerFirstYn) { + criticalDifferences.push("초도품 거부"); + } + + if (resp.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) { + criticalDifferences.push("스페어파트 거부"); + } + // 이 응답의 품목별 견적 아이템 가져오기 + const responseQuotationItems = quotationItems + .filter(q => q.vendorResponseId === resp.responseId) + .map(q => ({ + prItemId: q.prItemId, + unitPrice: q.unitPrice || 0, + totalPrice: q.totalPrice || 0, + currency: q.currency || "USD", + quantity: q.quantity || 0, + deliveryDate: q.deliveryDate, + leadTime: q.leadTime, + manufacturer: q.manufacturer, + modelNo: q.modelNo, + })); + + return { + responseId: resp.responseId, + responseVersion: resp.responseVersion, + participationStatus: resp.participationStatus || "미응답", + responseStatus: resp.responseStatus || "대기중", + submittedAt: resp.submittedAt, + + totalAmount: resp.totalAmount || 0, + currency: resp.currency || v.buyerCurrency || "USD", + rank: 0, // 나중에 계산 + priceVariance: resp.totalAmount ? ((resp.totalAmount - avgAmount) / avgAmount) * 100 : 0, + + vendorConditions: { + currency: resp.vendorCurrency || undefined, + paymentTermsCode: resp.vendorPaymentTermsCode || undefined, + paymentTermsDesc: paymentTermsMap.get(resp.vendorPaymentTermsCode || ""), + incotermsCode: resp.vendorIncotermsCode || undefined, + incotermsDesc: incotermsMap.get(resp.vendorIncotermsCode || ""), + deliveryDate: resp.vendorDeliveryDate, + contractDuration: resp.vendorContractDuration || undefined, + taxCode: resp.vendorTaxCode || undefined, + placeOfShipping: resp.vendorPlaceOfShipping || undefined, + placeOfDestination: resp.vendorPlaceOfDestination || undefined, + firstAcceptance: resp.vendorFirstAcceptance || undefined, + firstDescription: resp.vendorFirstDescription || undefined, + sparepartAcceptance: resp.vendorSparepartAcceptance || undefined, + sparepartDescription: resp.vendorSparepartDescription || undefined, + materialPriceRelatedYn: resp.vendorMaterialPriceRelatedYn || undefined, + materialPriceRelatedReason: resp.vendorMaterialPriceRelatedReason || undefined, + }, + + conditionDifferences: { + hasDifferences: differences.length > 0 || criticalDifferences.length > 0, + differences, + criticalDifferences, + }, + + generalRemark: resp.generalRemark, + technicalProposal: resp.technicalProposal, + quotationItems: responseQuotationItems, + }; + }); + + // 최신 응답 찾기 + const latestResp = responses.find(r => r.responseVersion === Math.max(...responses.map(r => r.responseVersion))); + return { vendorId: v.vendorId, vendorName: v.vendorName, - vendorCode: v.vendorCode, - vendorCountry: v.vendorCountry, - - responseId: v.responseId || 0, - participationStatus: v.participationStatus || "미응답", - responseStatus: v.responseStatus || "대기중", - submittedAt: v.submittedAt, - - totalAmount: v.totalAmount || 0, - currency: v.responseCurrency || v.buyerCurrency || "USD", - rank: 0, // 나중에 계산 - priceVariance: v.totalAmount ? ((v.totalAmount - avgAmount) / avgAmount) * 100 : 0, + vendorCode: v.vendorCode || "", + vendorCountry: v.vendorCountry || undefined, + // 구매자 제시 조건 buyerConditions: { currency: v.buyerCurrency || "USD", paymentTermsCode: v.buyerPaymentTermsCode || "", @@ -430,33 +578,48 @@ export async function getComparisonData( materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false, }, - vendorConditions: { - currency: v.vendorCurrency, - paymentTermsCode: v.vendorPaymentTermsCode, - paymentTermsDesc: paymentTermsMap.get(v.vendorPaymentTermsCode || ""), - incotermsCode: v.vendorIncotermsCode, - incotermsDesc: incotermsMap.get(v.vendorIncotermsCode || ""), - deliveryDate: v.vendorDeliveryDate, - contractDuration: v.vendorContractDuration, - taxCode: v.vendorTaxCode, - placeOfShipping: v.vendorPlaceOfShipping, - placeOfDestination: v.vendorPlaceOfDestination, - firstAcceptance: v.vendorFirstAcceptance, - firstDescription: v.vendorFirstDescription, - sparepartAcceptance: v.vendorSparepartAcceptance, - sparepartDescription: v.vendorSparepartDescription, - materialPriceRelatedYn: v.vendorMaterialPriceRelatedYn, - materialPriceRelatedReason: v.vendorMaterialPriceRelatedReason, + // 차수별 응답 배열 + responses, + latestResponse: latestResp, + + // 레거시 호환 필드 (최신 응답 기준) + responseId: latestResp?.responseId || 0, + participationStatus: latestResp?.participationStatus || "미응답", + responseStatus: latestResp?.responseStatus || "대기중", + submittedAt: latestResp?.submittedAt || null, + + totalAmount: latestResp?.totalAmount || 0, + currency: latestResp?.currency || v.buyerCurrency || "USD", + rank: 0, // 나중에 계산 + priceVariance: latestResp?.priceVariance || 0, + + vendorConditions: latestResp?.vendorConditions || { + currency: undefined, + paymentTermsCode: undefined, + paymentTermsDesc: undefined, + incotermsCode: undefined, + incotermsDesc: undefined, + deliveryDate: null, + contractDuration: undefined, + taxCode: undefined, + placeOfShipping: undefined, + placeOfDestination: undefined, + firstAcceptance: undefined, + firstDescription: undefined, + sparepartAcceptance: undefined, + sparepartDescription: undefined, + materialPriceRelatedYn: undefined, + materialPriceRelatedReason: undefined, }, - conditionDifferences: { - hasDifferences: differences.length > 0 || criticalDifferences.length > 0, - differences, - criticalDifferences, + conditionDifferences: latestResp?.conditionDifferences || { + hasDifferences: false, + differences: [], + criticalDifferences: [], }, - generalRemark: v.generalRemark, - technicalProposal: v.technicalProposal, + generalRemark: latestResp?.generalRemark, + technicalProposal: latestResp?.technicalProposal, // 선정 관련 정보 isSelected: v.isSelected || false, @@ -487,7 +650,10 @@ export async function getComparisonData( const itemQuotes = quotationItems .filter(q => q.prItemId === item.id) .map(q => { - const vendor = vendorData.find(v => v.responseId === q.vendorResponseId); + // vendorResponseId로 응답 찾기 + const response = allVendorResponses.find(r => r.responseId === q.vendorResponseId); + // 그 응답의 vendorId로 벤더 정보 찾기 + const vendor = vendorData.find(v => v.vendorId === response?.vendorId); return { vendorId: vendor?.vendorId || 0, vendorName: vendor?.vendorName || "", @@ -495,13 +661,13 @@ export async function getComparisonData( totalPrice: q.totalPrice || 0, currency: q.currency || "USD", quotedQuantity: q.quantity || 0, - deliveryDate: q.deliveryDate, - leadTime: q.leadTime, - manufacturer: q.manufacturer, - modelNo: q.modelNo, + deliveryDate: q.deliveryDate || undefined, + leadTime: q.leadTime || undefined, + manufacturer: q.manufacturer || undefined, + modelNo: q.modelNo || undefined, technicalCompliance: q.technicalCompliance || true, - alternativeProposal: q.alternativeProposal, - itemRemark: q.itemRemark, + alternativeProposal: q.alternativeProposal || undefined, + itemRemark: q.itemRemark || undefined, priceRank: 0, }; }); @@ -555,7 +721,14 @@ export async function getComparisonData( // 11. 최종 데이터 반환 return { - rfqInfo: rfqData[0], + rfqInfo: { + ...rfqData[0], + rfqCode: rfqData[0].rfqCode || "", + rfqTitle: rfqData[0].rfqTitle || "", + rfqType: rfqData[0].rfqType || "", + packageNo: rfqData[0].packageNo || undefined, + packageName: rfqData[0].packageName || undefined, + }, vendors: vendorComparisons, prItems: prItemComparisons, summary: { @@ -586,11 +759,17 @@ interface SelectVendorParams { priceRank: number; hasConditionDifferences: boolean; criticalDifferences: string[]; - userId: number; // 현재 사용자 ID } export async function selectVendor(params: SelectVendorParams) { try { + // 세션에서 사용자 ID 가져오기 + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + const userId = Number(session.user.id); + // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. RFQ 상태 확인 @@ -613,7 +792,7 @@ export async function selectVendor(params: SelectVendorParams) { .set({ isSelected: false, updatedAt: new Date(), - updatedBy: params.userId + updatedBy: userId }) .where( and( @@ -629,11 +808,11 @@ export async function selectVendor(params: SelectVendorParams) { isSelected: true, selectionDate: new Date(), selectionReason: params.selectionReason, - selectedBy: params.userId, + selectedBy: userId, totalAmount: params.totalAmount.toString(), priceRank: params.priceRank, updatedAt: new Date(), - updatedBy: params.userId, + updatedBy: userId, }) .where( and( @@ -868,16 +1047,8 @@ export async function processSelectionApproval( throw new Error("선정된 업체를 찾을 수 없습니다."); } - // 승인된 경우 RFQ 상태 업데이트 - if (action === "승인") { - await tx - .update(rfqsLast) - .set({ - status: "계약 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, rfqId)); - } + // 승인된 경우 RFQ 상태는 이미 "최종업체선정"이므로 별도 업데이트 불필요 + // (선정 시 이미 "최종업체선정"으로 설정됨) }); revalidatePath(`/evcp/rfq-last/${rfqId}`); @@ -950,8 +1121,8 @@ export async function getRfqQuotationStatus(rfqId: number) { .select({ id: rfqLastDetails.id, vendorId: rfqLastDetails.vendorsId, - vendorName: vendors.name, - vendorCode: vendors.code, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, totalAmount: rfqLastDetails.totalAmount, currency: rfqLastDetails.currency, priceRank: rfqLastDetails.priceRank, |
