From 0ee0f102634dd703aea7ad0b8a338eb5e9bdadab Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 16 Oct 2025 09:26:56 +0000 Subject: (최겸) 구매 견적 비교 수정(create-PO 개발 필) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rfq-last/compare-action.ts | 477 +++++++++++----- lib/rfq-last/contract-actions.ts | 5 +- lib/rfq-last/quotation-compare-view.tsx | 970 +++++++++++++++----------------- 3 files changed, 791 insertions(+), 661 deletions(-) (limited to 'lib') 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(); + 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, diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 082716a0..1f86352a 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -146,9 +146,10 @@ export async function createGeneralContract(params: CreateGeneralContractParams) .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); // 3. 계약번호 생성 - generateContractNumber 함수 사용 + // 매개변수 순서: (userId, contractType) const contractNumber = await generateContractNumber( - params.contractType, // 계약종류 (UP, LE, IL 등) - rfqData.rfq.picCode || undefined // 발주담당자 코드 + rfqData.rfq.picCode || undefined, // 발주담당자 코드 (userId) + params.contractType // 계약종류 (UP, LE, IL 등) ); // 4. 트랜잭션으로 계약 생성 diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 91d46295..723d1044 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -43,7 +43,13 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ComparisonData, selectVendor, cancelVendorSelection, VendorResponseVersion } from "./compare-action"; import { createPO, createGeneralContract, createBidding } from "./contract-actions"; import { toast } from "sonner"; import { useRouter } from "next/navigation" @@ -58,6 +64,9 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [showSelectionDialog, setShowSelectionDialog] = React.useState(false); const [showCancelDialog, setShowCancelDialog] = React.useState(false); const [showContractDialog, setShowContractDialog] = React.useState(false); + const [showItemDetailsDialog, setShowItemDetailsDialog] = React.useState(false); + const [selectedResponse, setSelectedResponse] = React.useState(null); + const [selectedVendorName, setSelectedVendorName] = React.useState(""); const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">(""); const [selectionReason, setSelectionReason] = React.useState(""); const [cancelReason, setCancelReason] = React.useState(""); @@ -88,7 +97,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { ]; // 입찰 관련 state 추가 - const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">(""); + const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale">("unit_price"); const [biddingType, setBiddingType] = React.useState(""); const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single"); const [biddingStartDate, setBiddingStartDate] = React.useState(""); @@ -236,7 +245,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { setSelectedGeneralContractType(""); setContractStartDate(""); setContractEndDate(""); - setBiddingContractType(""); + setBiddingContractType("unit_price"); setBiddingType(""); setAwardCount("single"); setBiddingStartDate(""); @@ -323,17 +332,23 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { throw new Error("선택한 업체를 찾을 수 없습니다."); } + // 최신 응답 정보 사용 + const latestResponse = vendor.responses[0]; + if (!latestResponse) { + throw new Error("업체의 견적 정보를 찾을 수 없습니다."); + } + const result = await selectVendor({ rfqId: data.rfqInfo.id, vendorId: vendor.vendorId, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode, - totalAmount: vendor.totalAmount, - currency: vendor.currency, + totalAmount: latestResponse.totalAmount, + currency: latestResponse.currency, selectionReason: selectionReason, - priceRank: vendor.rank || 0, - hasConditionDifferences: vendor.conditionDifferences.hasDifferences, - criticalDifferences: vendor.conditionDifferences.criticalDifferences, + priceRank: latestResponse.rank || 0, + hasConditionDifferences: latestResponse.conditionDifferences.hasDifferences, + criticalDifferences: latestResponse.conditionDifferences.criticalDifferences, }); if (result.success) { @@ -526,7 +541,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} {/* 요약 카드 */} -
+
{/* 최저가 벤더 */} @@ -577,147 +592,111 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} - - {/* 가격 범위 */} - - - - - 가격 범위 - - - -

- {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}% -

-

- 최저가 대비 최고가 차이 -

-
-
- - {/* 조건 불일치 */} - - - - - 조건 불일치 - - - -

- {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개 -

-

- 제시 조건과 차이 있음 -

-
-
{/* 탭 뷰 */} - + 종합 비교 조건 비교 - 아이템별 비교 - 상세 분석 {/* 종합 비교 */} - 가격 순위 및 업체 선정 + 협력업체별 차수별 견적 -
- {data.vendors.map((vendor) => ( -
{ - if (!hasSelection) { - setSelectedVendorId(vendor.vendorId.toString()); - } - }} - > -
- {!hasSelection && ( - setSelectedVendorId(e.target.value)} - className="h-4 w-4 text-blue-600" - /> - )} -
+ + + + {!hasSelection && } + + + {(() => { + // 모든 차수 추출 (중복 제거 및 내림차순 정렬) + const allVersions = Array.from( + new Set( + data.vendors.flatMap(v => v.responses.map(r => r.responseVersion)) + ) + ).sort((a, b) => b - a); + + return allVersions.map(version => ( + + )); + })()} + + + + {data.vendors.map((vendor) => ( + - {vendor.rank} - -
-

- {vendor.vendorName} - {vendor.isSelected && ( - 선정 - )} -

-

- {vendor.vendorCode} • {vendor.vendorCountry} -

-
- - -
- {/* 조건 차이 표시 */} - {vendor.conditionDifferences.criticalDifferences.length > 0 && ( - - - - - - 중요 차이 {vendor.conditionDifferences.criticalDifferences.length} - - - -
- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( -

{diff}

- ))} -
-
-
-
- )} - - {/* 가격 정보 */} -
-

- {formatAmount(vendor.totalAmount, vendor.currency)} -

-

- {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""} - {vendor.priceVariance?.toFixed(1)}% vs 평균 -

-
-
- - ))} + {!hasSelection && ( + + )} + + + {(() => { + const allVersions = Array.from( + new Set( + data.vendors.flatMap(v => v.responses.map(r => r.responseVersion)) + ) + ).sort((a, b) => b - a); + + return allVersions.map(version => { + const response = vendor.responses.find(r => r.responseVersion === version); + + return ( + + ); + }); + })()} + + ))} + +
선택협력사 코드협력사 명 + {version}차 +
+ setSelectedVendorId(e.target.value)} + className="h-4 w-4 text-blue-600" + /> + {vendor.vendorCode} +
+ {vendor.vendorName} + {vendor.isSelected && ( + 선정 + )} +
+
+ {response ? ( + + ) : ( + - + )} +
@@ -733,16 +712,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { - - + + {data.vendors.map((vendor) => ( ))} @@ -751,30 +730,33 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 통화 */} - - - {data.vendors.map((vendor) => ( - - ))} + + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; // 최신 응답 (이미 정렬되어 있음) + return ( + + ); + })} {/* 지급조건 */} - - + - {data.vendors.map((vendor) => ( - - ))} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + + ); + })} - {/* 나머지 조건들도 동일한 패턴으로 처리 */} - -
항목구매자 제시항목구매자 제시 {vendor.vendorName} {vendor.isSelected && ( - 선정 + 선정 )}
통화{data.vendors[0]?.buyerConditions.currency} -
- {vendor.vendorConditions.currency || vendor.buyerConditions.currency} - {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && ( - 변경 - )} -
-
통화{data.vendors[0]?.buyerConditions.currency} +
+ {latestResponse?.vendorConditions?.currency || vendor.buyerConditions.currency} + {latestResponse?.vendorConditions?.currency && latestResponse.vendorConditions.currency !== vendor.buyerConditions.currency && ( + 변경 + )} +
+
지급조건 + 지급조건 - + {data.vendors[0]?.buyerConditions.paymentTermsCode} @@ -783,327 +765,298 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { -
- - - - {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} - - - {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} - - - - {vendor.vendorConditions.paymentTermsCode && - vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - 변경 - )} -
-
+
+ + + + {latestResponse?.vendorConditions?.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} + + + {latestResponse?.vendorConditions?.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} + + + + {latestResponse?.vendorConditions?.paymentTermsCode && + latestResponse.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + 변경 + )} +
+
- - - - - {/* 아이템별 비교 */} - - - - PR 아이템별 가격 비교 - - -
- {data.prItems.map((item) => ( - toggleItemExpansion(item.prItemId)} - > -
- -
-
-
- {expandedItems.has(item.prItemId) ? ( - - ) : ( - + {/* 인코텀즈 */} + + 인코텀즈 + + + + + {data.vendors[0]?.buyerConditions.incotermsCode} + + + {data.vendors[0]?.buyerConditions.incotermsDesc} + + + + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ + + + {latestResponse?.vendorConditions?.incotermsCode || vendor.buyerConditions.incotermsCode} + + + {latestResponse?.vendorConditions?.incotermsDesc || vendor.buyerConditions.incotermsDesc} + + + + {latestResponse?.vendorConditions?.incotermsCode && + latestResponse.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( + 변경 )} - -
-
-

{item.materialDescription}

-

- {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom} -

-
-
-

단가 범위

-

- {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)} -

-
-
- - - -
- - - - - - - - - - - - - - {item.vendorQuotes.map((quote) => ( - - - - - - - - - - ))} - -
벤더단가총액수량납기제조사순위
{quote.vendorName} - {formatAmount(quote.unitPrice, quote.currency)} - - {formatAmount(quote.totalPrice, quote.currency)} - {quote.quotedQuantity} - {quote.deliveryDate - ? format(new Date(quote.deliveryDate), "yyyy-MM-dd") - : quote.leadTime - ? `${quote.leadTime}일` - : "-"} - - {quote.manufacturer && ( -
-

{quote.manufacturer}

- {quote.modelNo && ( -

{quote.modelNo}

- )} -
- )} -
- - #{quote.priceRank} - -
- - {/* 가격 분석 요약 */} -
-
-
-

평균 단가

-

- {formatAmount(item.priceAnalysis.averagePrice)} -

-
-
-

가격 편차

-

- ±{formatAmount(item.priceAnalysis.priceVariance)} -

-
-
-

최저가 업체

-

- {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName} -

-
-
-

가격 차이

-

- {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / - item.priceAnalysis.lowestPrice * 100).toFixed(1)}% -

-
-
-
-
-
-
- - ))} -
- - - + + ); + })} + - {/* 상세 분석 */} - -
- {/* 위험 요소 분석 */} - - - - - 위험 요소 분석 - - - -
- {data.vendors.map((vendor) => { - if (!vendor.conditionDifferences.hasDifferences) return null; - - return ( -
-

{vendor.vendorName}

- {vendor.conditionDifferences.criticalDifferences.length > 0 && ( -
-

중요 차이점:

- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => ( -

• {diff}

- ))} -
- )} - {vendor.conditionDifferences.differences.length > 0 && ( -
-

일반 차이점:

- {vendor.conditionDifferences.differences.map((diff, idx) => ( -

• {diff}

- ))} + {/* 선적지 */} + + 선적지 + {data.vendors[0]?.buyerConditions.placeOfShipping || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {latestResponse?.vendorConditions?.placeOfShipping || vendor.buyerConditions.placeOfShipping || "-"} + {latestResponse?.vendorConditions?.placeOfShipping && + latestResponse.vendorConditions.placeOfShipping !== vendor.buyerConditions.placeOfShipping && ( + 변경 + )}
- )} -
- ); - })} -
- - - - {/* 추천 사항 */} - - - - - 선정 추천 - - - -
- {/* 가격 기준 추천 */} -
-

가격 우선 선정

-

- {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)} -

- {data.vendors[0]?.conditionDifferences.hasDifferences && ( -

- ⚠️ 조건 차이 검토 필요 -

- )} -
+ + ); + })} + - {/* 조건 준수 기준 추천 */} -
-

조건 준수 우선 선정

- {(() => { - const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences); - if (compliantVendor) { - return ( -
-

- {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)} -

-

- 모든 조건 충족 (가격 순위: #{compliantVendor.rank}) -

-
- ); - } + {/* 하역지 */} + + 하역지 + {data.vendors[0]?.buyerConditions.placeOfDestination || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( -

- 모든 조건을 충족하는 벤더 없음 -

+ +
+ {latestResponse?.vendorConditions?.placeOfDestination || vendor.buyerConditions.placeOfDestination || "-"} + {latestResponse?.vendorConditions?.placeOfDestination && + latestResponse.vendorConditions.placeOfDestination !== vendor.buyerConditions.placeOfDestination && ( + 변경 + )} +
+ ); - })()} -
+ })} + - {/* 균형 추천 */} -
-

균형 선정 (추천)

- {(() => { - // 가격 순위와 조건 차이를 고려한 점수 계산 - const scoredVendors = data.vendors.map(v => ({ - ...v, - score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + - v.conditionDifferences.differences.length - })); - scoredVendors.sort((a, b) => a.score - b.score); - const recommended = scoredVendors[0]; + {/* 납기일 */} + + 납기일 + + {data.vendors[0]?.buyerConditions.deliveryDate + ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {latestResponse?.vendorConditions?.deliveryDate + ? format(new Date(latestResponse.vendorConditions.deliveryDate), "yyyy-MM-dd") + : vendor.buyerConditions.deliveryDate + ? format(new Date(vendor.buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + {latestResponse?.vendorConditions?.deliveryDate && vendor.buyerConditions.deliveryDate && + new Date(latestResponse.vendorConditions.deliveryDate).getTime() !== new Date(vendor.buyerConditions.deliveryDate).getTime() && ( + 변경 + )} +
+ + ); + })} + + {/* 세금조건 */} + + 세금조건 + {data.vendors[0]?.buyerConditions.taxCode || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; return ( -
-

- {recommended.vendorName} - {formatAmount(recommended.totalAmount)} -

-

- 가격 순위 #{recommended.rank}, 조건 차이 최소화 -

-
+ +
+ {latestResponse?.vendorConditions?.taxCode || vendor.buyerConditions.taxCode || "-"} + {latestResponse?.vendorConditions?.taxCode && + latestResponse.vendorConditions.taxCode !== vendor.buyerConditions.taxCode && ( + 변경 + )} +
+ ); - })()} -
-
-
-
-
+ })} + - {/* 벤더별 비고사항 */} - {data.vendors.some(v => v.generalRemark || v.technicalProposal) && ( - - - 벤더 제안사항 및 비고 - - -
- {data.vendors.map((vendor) => { - if (!vendor.generalRemark && !vendor.technicalProposal) return null; - - return ( -
-

{vendor.vendorName}

- {vendor.generalRemark && ( -
-

일반 비고:

-

{vendor.generalRemark}

+ {/* 계약기간 */} + + 계약기간 + {data.vendors[0]?.buyerConditions.contractDuration || "-"} + {data.vendors.map((vendor) => { + const latestResponse = vendor.responses[0]; + return ( + +
+ {latestResponse?.vendorConditions?.contractDuration || vendor.buyerConditions.contractDuration || "-"} + {latestResponse?.vendorConditions?.contractDuration && + latestResponse.vendorConditions.contractDuration !== vendor.buyerConditions.contractDuration && ( + 변경 + )}
- )} - {vendor.technicalProposal && ( + + ); + })} + + + + + + + + + {/* 품목별 상세 정보 다이얼로그 */} + + + + + {selectedVendorName} - {selectedResponse?.responseVersion}차 품목별 견적 상세 + + + +
+ + + + + + + + + + + + + + {selectedResponse?.quotationItems?.map((quoteItem) => { + const prItem = data.prItems.find(item => item.prItemId === quoteItem.prItemId); + if (!prItem) return null; + + return ( + + + + + + + + + + ); + })} + +
품목코드품목명수량단가총액납기제조사
{prItem.materialCode} +

{prItem.materialDescription}

+

+ {prItem.prNo} • {prItem.prItem} +

+
+ {quoteItem.quantity} {prItem.uom} + + {formatAmount(quoteItem.unitPrice, quoteItem.currency)} + + {formatAmount(quoteItem.totalPrice, quoteItem.currency)} + + {quoteItem.deliveryDate + ? format(new Date(quoteItem.deliveryDate), "yyyy-MM-dd") + : quoteItem.leadTime + ? `${quoteItem.leadTime}일` + : "-"} + + {quoteItem.manufacturer ? (
-

기술 제안:

-

{vendor.technicalProposal}

+

{quoteItem.manufacturer}

+ {quoteItem.modelNo && ( +

{quoteItem.modelNo}

+ )}
- )} - - ); - })} - - - + ) : "-"} +
+
+ + {/* 비고사항 */} + {(selectedResponse?.generalRemark || selectedResponse?.technicalProposal) && ( +
+

비고사항

+
+ {selectedResponse.generalRemark && ( +
+

일반 비고:

+

{selectedResponse.generalRemark}

+
+ )} + {selectedResponse.technicalProposal && ( +
+

기술 제안:

+

{selectedResponse.technicalProposal}

+
+ )} +
+
)} - - +
+
+ {/* 업체 선정 모달 */} {showSelectionDialog && ( @@ -1113,57 +1066,62 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} - {selectedVendorId && ( -
-
-
-
- 선정 업체 - - {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.vendorName} - -
-
- 견적 금액 - - {formatAmount( - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.totalAmount || 0, - data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.currency - )} - -
-
- 가격 순위 - - #{data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.rank || 0} - + {selectedVendorId && (() => { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + const latestResponse = vendor?.responses[0]; + + return ( +
+
+
+
+ 선정 업체 + + {vendor?.vendorName} + +
+
+ 견적 금액 + + {formatAmount( + latestResponse?.totalAmount || 0, + latestResponse?.currency || "USD" + )} + +
+
+ 가격 순위 + + #{latestResponse?.rank || 0} + +
+ {latestResponse?.conditionDifferences.hasDifferences && ( + + + + 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. + + + )}
- {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && ( - - - - 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. - - - )}
-
-
- -