summaryrefslogtreecommitdiff
path: root/lib/rfq-last/compare-action.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/compare-action.ts')
-rw-r--r--lib/rfq-last/compare-action.ts500
1 files changed, 500 insertions, 0 deletions
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts
new file mode 100644
index 00000000..5d210631
--- /dev/null
+++ b/lib/rfq-last/compare-action.ts
@@ -0,0 +1,500 @@
+"use server";
+
+import db from "@/db/db";
+import { eq, and, inArray } from "drizzle-orm";
+import {
+ rfqsLast,
+ rfqLastDetails,
+ rfqPrItems,
+ rfqLastVendorResponses,
+ rfqLastVendorQuotationItems,
+ vendors,
+ paymentTerms,
+ incoterms,
+} from "@/db/schema";
+
+export interface ComparisonData {
+ rfqInfo: {
+ id: number;
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ dueDate: Date | null;
+ packageNo?: string;
+ packageName?: string;
+ };
+ vendors: VendorComparison[];
+ prItems: PrItemComparison[];
+ summary: {
+ lowestBidder: string;
+ highestBidder: string;
+ priceRange: {
+ min: number;
+ max: number;
+ average: number;
+ };
+ currency: string;
+ };
+}
+
+export interface VendorComparison {
+ vendorId: number;
+ vendorName: string;
+ vendorCode: string;
+ vendorCountry?: string;
+
+ // 응답 정보
+ responseId: number;
+ participationStatus: string;
+ responseStatus: string;
+ submittedAt: Date | null;
+
+ // 가격 정보
+ totalAmount: number;
+ currency: string;
+ rank?: number;
+ priceVariance?: number; // 평균 대비 차이 %
+
+ // 구매자 제시 조건
+ buyerConditions: {
+ currency: string;
+ paymentTermsCode: string;
+ paymentTermsDesc?: string;
+ incotermsCode: string;
+ incotermsDesc?: string;
+ deliveryDate: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+
+ // 추가 조건
+ firstYn: boolean;
+ firstDescription?: string;
+ sparepartYn: boolean;
+ sparepartDescription?: string;
+ materialPriceRelatedYn: boolean;
+ };
+
+ // 벤더 제안 조건
+ 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;
+}
+
+export interface PrItemComparison {
+ prItemId: number;
+ prNo: string;
+ prItem: string;
+ materialCode: string;
+ materialDescription: string;
+ requestedQuantity: number;
+ uom: string;
+ requestedDeliveryDate: Date | null;
+
+ vendorQuotes: {
+ vendorId: number;
+ vendorName: string;
+ unitPrice: number;
+ totalPrice: number;
+ currency: string;
+ quotedQuantity: number;
+ deliveryDate?: Date | null;
+ leadTime?: number;
+ manufacturer?: string;
+ modelNo?: string;
+ technicalCompliance: boolean;
+ alternativeProposal?: string;
+ itemRemark?: string;
+ priceRank?: number;
+ }[];
+
+ priceAnalysis: {
+ lowestPrice: number;
+ highestPrice: number;
+ averagePrice: number;
+ priceVariance: number; // 표준편차
+ };
+}
+
+export async function getComparisonData(
+ rfqId: number,
+ vendorIds: number[]
+): Promise<ComparisonData | null> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqTitle: rfqsLast.rfqTitle,
+ rfqType: rfqsLast.rfqType,
+ // projectCode: rfqsLast.projectCode,
+ // projectName: rfqsLast.projectName,
+ dueDate: rfqsLast.dueDate,
+ packageNo: rfqsLast.packageNo,
+ packageName: rfqsLast.packageName,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData[0]) return null;
+
+ // 2. 벤더별 정보 및 응답 조회
+ const vendorData = await db
+ .select({
+ // 벤더 정보
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorCountry: vendors.country,
+
+ // RFQ Details (구매자 조건)
+ detailId: rfqLastDetails.id,
+ buyerCurrency: rfqLastDetails.currency,
+ buyerPaymentTermsCode: rfqLastDetails.paymentTermsCode,
+ buyerIncotermsCode: rfqLastDetails.incotermsCode,
+ buyerIncotermsDetail: rfqLastDetails.incotermsDetail,
+ buyerDeliveryDate: rfqLastDetails.deliveryDate,
+ buyerContractDuration: rfqLastDetails.contractDuration,
+ buyerTaxCode: rfqLastDetails.taxCode,
+ buyerPlaceOfShipping: rfqLastDetails.placeOfShipping,
+ buyerPlaceOfDestination: rfqLastDetails.placeOfDestination,
+ buyerFirstYn: rfqLastDetails.firstYn,
+ buyerFirstDescription: rfqLastDetails.firstDescription,
+ buyerSparepartYn: rfqLastDetails.sparepartYn,
+ buyerSparepartDescription: rfqLastDetails.sparepartDescription,
+ buyerMaterialPriceRelatedYn: rfqLastDetails.materialPriceRelatedYn,
+
+ // 벤더 응답
+ responseId: rfqLastVendorResponses.id,
+ participationStatus: rfqLastVendorResponses.participationStatus,
+ responseStatus: rfqLastVendorResponses.status,
+ submittedAt: rfqLastVendorResponses.submittedAt,
+ totalAmount: rfqLastVendorResponses.totalAmount,
+ responseCurrency: rfqLastVendorResponses.currency,
+
+ // 벤더 제안 조건
+ vendorCurrency: rfqLastVendorResponses.vendorCurrency,
+ vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode,
+ vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode,
+ vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail,
+ vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate,
+ vendorContractDuration: rfqLastVendorResponses.vendorContractDuration,
+ vendorTaxCode: rfqLastVendorResponses.vendorTaxCode,
+ vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping,
+ vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination,
+
+ // 추가 조건 응답
+ vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance,
+ vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription,
+ vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance,
+ vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription,
+ vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn,
+ vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason,
+
+ // 비고
+ 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,
+ and(
+ eq(rfqLastVendorResponses.vendorId, vendors.id),
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .where(inArray(vendors.id, vendorIds));
+
+ // 3. Payment Terms와 Incoterms 설명 조회
+ const paymentTermsData = await db
+ .select({
+ code: paymentTerms.code,
+ description: paymentTerms.description,
+ })
+ .from(paymentTerms);
+
+ const incotermsData = await db
+ .select({
+ code: incoterms.code,
+ description: incoterms.description,
+ })
+ .from(incoterms);
+
+ const paymentTermsMap = new Map(
+ paymentTermsData.map(pt => [pt.code, pt.description])
+ );
+ const incotermsMap = new Map(
+ incotermsData.map(ic => [ic.code, ic.description])
+ );
+
+ // 4. PR Items 조회
+ const prItems = await db
+ .select({
+ id: rfqPrItems.id,
+ prNo: rfqPrItems.prNo,
+ prItem: rfqPrItems.prItem,
+ materialCode: rfqPrItems.materialCode,
+ materialDescription: rfqPrItems.materialDescription,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ deliveryDate: rfqPrItems.deliveryDate,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ // 5. 벤더별 견적 아이템 조회
+ 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)
+ )
+ );
+
+ // 6. 데이터 가공 및 분석
+ 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;
+
+ // 벤더별 비교 데이터 구성
+ 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}`);
+ }
+
+ 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))}일 지연`);
+ }
+ }
+
+ if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) {
+ criticalDifferences.push("초도품 거부");
+ }
+
+ if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) {
+ criticalDifferences.push("스페어파트 거부");
+ }
+
+ 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,
+
+ buyerConditions: {
+ currency: v.buyerCurrency || "USD",
+ paymentTermsCode: v.buyerPaymentTermsCode || "",
+ paymentTermsDesc: paymentTermsMap.get(v.buyerPaymentTermsCode || ""),
+ incotermsCode: v.buyerIncotermsCode || "",
+ incotermsDesc: incotermsMap.get(v.buyerIncotermsCode || ""),
+ deliveryDate: v.buyerDeliveryDate,
+ contractDuration: v.buyerContractDuration,
+ taxCode: v.buyerTaxCode,
+ placeOfShipping: v.buyerPlaceOfShipping,
+ placeOfDestination: v.buyerPlaceOfDestination,
+ firstYn: v.buyerFirstYn || false,
+ firstDescription: v.buyerFirstDescription,
+ sparepartYn: v.buyerSparepartYn || false,
+ sparepartDescription: v.buyerSparepartDescription,
+ 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,
+ },
+
+ conditionDifferences: {
+ hasDifferences: differences.length > 0 || criticalDifferences.length > 0,
+ differences,
+ criticalDifferences,
+ },
+
+ generalRemark: v.generalRemark,
+ technicalProposal: v.technicalProposal,
+ };
+ });
+
+ // 가격 순위 계산
+ vendorComparisons.sort((a, b) => a.totalAmount - b.totalAmount);
+ vendorComparisons.forEach((v, index) => {
+ v.rank = index + 1;
+ });
+
+ // PR 아이템별 비교 데이터 구성
+ const prItemComparisons: PrItemComparison[] = prItems.map(item => {
+ const itemQuotes = quotationItems
+ .filter(q => q.prItemId === item.id)
+ .map(q => {
+ const vendor = vendorData.find(v => v.responseId === q.vendorResponseId);
+ return {
+ vendorId: vendor?.vendorId || 0,
+ vendorName: vendor?.vendorName || "",
+ unitPrice: q.unitPrice || 0,
+ totalPrice: q.totalPrice || 0,
+ currency: q.currency || "USD",
+ quotedQuantity: q.quantity || 0,
+ deliveryDate: q.deliveryDate,
+ leadTime: q.leadTime,
+ manufacturer: q.manufacturer,
+ modelNo: q.modelNo,
+ technicalCompliance: q.technicalCompliance || true,
+ alternativeProposal: q.alternativeProposal,
+ itemRemark: q.itemRemark,
+ priceRank: 0,
+ };
+ });
+
+ // 아이템별 가격 순위
+ itemQuotes.sort((a, b) => a.unitPrice - b.unitPrice);
+ itemQuotes.forEach((q, index) => {
+ q.priceRank = index + 1;
+ });
+
+ const unitPrices = itemQuotes.map(q => q.unitPrice);
+ const avgPrice = unitPrices.reduce((a, b) => a + b, 0) / unitPrices.length || 0;
+ const variance = Math.sqrt(
+ unitPrices.reduce((sum, price) => sum + Math.pow(price - avgPrice, 2), 0) / unitPrices.length
+ );
+
+ return {
+ prItemId: item.id,
+ prNo: item.prNo || "",
+ prItem: item.prItem || "",
+ materialCode: item.materialCode || "",
+ materialDescription: item.materialDescription || "",
+ requestedQuantity: item.quantity || 0,
+ uom: item.uom || "",
+ requestedDeliveryDate: item.deliveryDate,
+ vendorQuotes: itemQuotes,
+ priceAnalysis: {
+ lowestPrice: Math.min(...unitPrices) || 0,
+ highestPrice: Math.max(...unitPrices) || 0,
+ averagePrice: avgPrice,
+ priceVariance: variance,
+ },
+ };
+ });
+
+ // 최종 데이터 구성
+ return {
+ rfqInfo: rfqData[0],
+ vendors: vendorComparisons,
+ prItems: prItemComparisons,
+ summary: {
+ lowestBidder: vendorComparisons[0]?.vendorName || "",
+ highestBidder: vendorComparisons[vendorComparisons.length - 1]?.vendorName || "",
+ priceRange: {
+ min: minAmount,
+ max: maxAmount,
+ average: avgAmount,
+ },
+ currency: vendorComparisons[0]?.currency || "USD",
+ },
+ };
+ } catch (error) {
+ console.error("견적 비교 데이터 조회 실패:", error);
+ return null;
+ }
+} \ No newline at end of file