feat: multi-dim comparison + clustering + per-supplier PDF export

Backend:
- BizComparisonVO: add composite score fields (priceScore, deliveryScore,
  qualityScore, serviceScore, compositeScore, rankBadge) + SupplierPlan cluster VO
- Mapper: join biz_supplier_evaluation for quality/service history scores
- Service: weighted scoring (price 40%/delivery 25%/quality 20%/service 15%),
  greedy clustering assigns each item to best-score supplier, groups into plans
- Controller: returns CompareResult with items + recommendedPlans

Frontend:
- Tab 1 (多维度比价): supplier rank cards with 4-dim progress bars
- Tab 2 (智能推荐方案): per-supplier cluster cards with explanation + PDF export
- PDF: logo header, score legend, items table, cluster reason per supplier
This commit is contained in:
2026-05-22 11:19:29 +08:00
parent 1bc99dcc7c
commit 608ee0ed61
7 changed files with 823 additions and 111 deletions

View File

@@ -10,8 +10,10 @@ import com.ruoyi.system.service.bid.IBizComparisonService;
@RestController
@RequestMapping("/bid/comparison")
public class BizComparisonController extends BaseController {
@Autowired private IBizComparisonService service;
/** 多维比价 + 聚类推荐方案 */
@PreAuthorize("@ss.hasPermi('bid:comparison:list')")
@GetMapping("/rfq/{rfqId}")
public AjaxResult compareRfq(@PathVariable Long rfqId) {

View File

@@ -3,52 +3,167 @@ package com.ruoyi.system.domain.bid;
import java.math.BigDecimal;
import java.util.List;
/**
* 比价结果 VO (含多维评分 + 聚类推荐方案)
*/
public class BizComparisonVO {
// ---------- 物料行 ----------
private Long rfqItemId;
private String materialName;
private String spec;
private String unit;
private BigDecimal quantity;
private List<BizComparisonVO.SupplierPrice> prices;
private List<SupplierPrice> prices;
// ---------- 单个供应商在该物料上的报价 ----------
public static class SupplierPrice {
private Long supplierId;
private String supplierName;
private Long quotationId;
private String quoteNo;
// 原始数据
private BigDecimal unitPrice;
private BigDecimal totalPrice;
private Integer deliveryDays;
private boolean lowestPrice;
public Long getSupplierId() { return supplierId; }
public void setSupplierId(Long supplierId) { this.supplierId = supplierId; }
public String getSupplierName() { return supplierName; }
public void setSupplierName(String supplierName) { this.supplierName = supplierName; }
public Long getQuotationId() { return quotationId; }
public void setQuotationId(Long quotationId) { this.quotationId = quotationId; }
public String getQuoteNo() { return quoteNo; }
public void setQuoteNo(String quoteNo) { this.quoteNo = quoteNo; }
public BigDecimal getUnitPrice() { return unitPrice; }
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
public BigDecimal getTotalPrice() { return totalPrice; }
public void setTotalPrice(BigDecimal totalPrice) { this.totalPrice = totalPrice; }
public Integer getDeliveryDays() { return deliveryDays; }
public void setDeliveryDays(Integer deliveryDays) { this.deliveryDays = deliveryDays; }
public boolean isLowestPrice() { return lowestPrice; }
public void setLowestPrice(boolean lowestPrice) { this.lowestPrice = lowestPrice; }
// 多维评分 (均 0-100)
private double priceScore; // 价格得分
private double deliveryScore; // 交期得分
private double qualityScore; // 质量历史得分
private double serviceScore; // 服务历史得分
private double compositeScore; // 综合得分 = 0.40P+0.25D+0.20Q+0.15S
// 辅助字段
private boolean lowestPrice;
private String rankBadge; // "最优综合", "价格最低", "交期最快"
private double evalAvgScore; // 历史平均评分(原始1-5)
private int historyCount; // 历史成交次数
// ---- getters/setters ----
public Long getSupplierId(){return supplierId;}
public void setSupplierId(Long v){supplierId=v;}
public String getSupplierName(){return supplierName;}
public void setSupplierName(String v){supplierName=v;}
public Long getQuotationId(){return quotationId;}
public void setQuotationId(Long v){quotationId=v;}
public String getQuoteNo(){return quoteNo;}
public void setQuoteNo(String v){quoteNo=v;}
public BigDecimal getUnitPrice(){return unitPrice;}
public void setUnitPrice(BigDecimal v){unitPrice=v;}
public BigDecimal getTotalPrice(){return totalPrice;}
public void setTotalPrice(BigDecimal v){totalPrice=v;}
public Integer getDeliveryDays(){return deliveryDays;}
public void setDeliveryDays(Integer v){deliveryDays=v;}
public double getPriceScore(){return priceScore;}
public void setPriceScore(double v){priceScore=v;}
public double getDeliveryScore(){return deliveryScore;}
public void setDeliveryScore(double v){deliveryScore=v;}
public double getQualityScore(){return qualityScore;}
public void setQualityScore(double v){qualityScore=v;}
public double getServiceScore(){return serviceScore;}
public void setServiceScore(double v){serviceScore=v;}
public double getCompositeScore(){return compositeScore;}
public void setCompositeScore(double v){compositeScore=v;}
public boolean isLowestPrice(){return lowestPrice;}
public void setLowestPrice(boolean v){lowestPrice=v;}
public String getRankBadge(){return rankBadge;}
public void setRankBadge(String v){rankBadge=v;}
public double getEvalAvgScore(){return evalAvgScore;}
public void setEvalAvgScore(double v){evalAvgScore=v;}
public int getHistoryCount(){return historyCount;}
public void setHistoryCount(int v){historyCount=v;}
}
public Long getRfqItemId() { return rfqItemId; }
public void setRfqItemId(Long rfqItemId) { this.rfqItemId = rfqItemId; }
public String getMaterialName() { return materialName; }
public void setMaterialName(String materialName) { this.materialName = materialName; }
public String getSpec() { return spec; }
public void setSpec(String spec) { this.spec = spec; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public List<SupplierPrice> getPrices() { return prices; }
public void setPrices(List<SupplierPrice> prices) { this.prices = prices; }
// ---------- 聚类推荐方案:每个供应商一个采购计划 ----------
public static class SupplierPlan {
private Long supplierId;
private String supplierName;
private String contact;
private String phone;
private BigDecimal totalAmount;
private double avgCompositeScore;
private String clusterReason;
private List<PlanItem> items;
public static class PlanItem {
private String materialName;
private String spec;
private String unit;
private BigDecimal quantity;
private BigDecimal unitPrice;
private BigDecimal totalPrice;
private Integer deliveryDays;
private double compositeScore;
private String rankBadge;
public String getMaterialName(){return materialName;}
public void setMaterialName(String v){materialName=v;}
public String getSpec(){return spec;}
public void setSpec(String v){spec=v;}
public String getUnit(){return unit;}
public void setUnit(String v){unit=v;}
public BigDecimal getQuantity(){return quantity;}
public void setQuantity(BigDecimal v){quantity=v;}
public BigDecimal getUnitPrice(){return unitPrice;}
public void setUnitPrice(BigDecimal v){unitPrice=v;}
public BigDecimal getTotalPrice(){return totalPrice;}
public void setTotalPrice(BigDecimal v){totalPrice=v;}
public Integer getDeliveryDays(){return deliveryDays;}
public void setDeliveryDays(Integer v){deliveryDays=v;}
public double getCompositeScore(){return compositeScore;}
public void setCompositeScore(double v){compositeScore=v;}
public String getRankBadge(){return rankBadge;}
public void setRankBadge(String v){rankBadge=v;}
}
public Long getSupplierId(){return supplierId;}
public void setSupplierId(Long v){supplierId=v;}
public String getSupplierName(){return supplierName;}
public void setSupplierName(String v){supplierName=v;}
public String getContact(){return contact;}
public void setContact(String v){contact=v;}
public String getPhone(){return phone;}
public void setPhone(String v){phone=v;}
public BigDecimal getTotalAmount(){return totalAmount;}
public void setTotalAmount(BigDecimal v){totalAmount=v;}
public double getAvgCompositeScore(){return avgCompositeScore;}
public void setAvgCompositeScore(double v){avgCompositeScore=v;}
public String getClusterReason(){return clusterReason;}
public void setClusterReason(String v){clusterReason=v;}
public List<PlanItem> getItems(){return items;}
public void setItems(List<PlanItem> v){items=v;}
}
// ---------- 最终返回:包裹物料列表 + 推荐方案 ----------
public static class CompareResult {
private List<BizComparisonVO> items;
private List<SupplierPlan> recommendedPlans;
private String rfqTitle;
private String rfqNo;
public List<BizComparisonVO> getItems(){return items;}
public void setItems(List<BizComparisonVO> v){items=v;}
public List<SupplierPlan> getRecommendedPlans(){return recommendedPlans;}
public void setRecommendedPlans(List<SupplierPlan> v){recommendedPlans=v;}
public String getRfqTitle(){return rfqTitle;}
public void setRfqTitle(String v){rfqTitle=v;}
public String getRfqNo(){return rfqNo;}
public void setRfqNo(String v){rfqNo=v;}
}
// ---- BizComparisonVO getters/setters ----
public Long getRfqItemId(){return rfqItemId;}
public void setRfqItemId(Long v){rfqItemId=v;}
public String getMaterialName(){return materialName;}
public void setMaterialName(String v){materialName=v;}
public String getSpec(){return spec;}
public void setSpec(String v){spec=v;}
public String getUnit(){return unit;}
public void setUnit(String v){unit=v;}
public BigDecimal getQuantity(){return quantity;}
public void setQuantity(BigDecimal v){quantity=v;}
public List<SupplierPrice> getPrices(){return prices;}
public void setPrices(List<SupplierPrice> v){prices=v;}
}

View File

@@ -1,10 +1,10 @@
package com.ruoyi.system.mapper.bid;
import com.ruoyi.system.domain.bid.BizComparisonVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
public interface BizComparisonMapper {
List<Map<String, Object>> selectComparisonData(@Param("rfqId") Long rfqId, @Param("tenantId") Long tenantId);
List<Map<String, Object>> selectComparisonData(@Param("rfqId") Long rfqId);
Map<String, Object> selectRfqInfo(@Param("rfqId") Long rfqId);
}

View File

@@ -1,8 +1,8 @@
package com.ruoyi.system.service.bid;
import com.ruoyi.system.domain.bid.BizComparisonVO;
import java.util.List;
public interface IBizComparisonService {
List<BizComparisonVO> compareRfq(Long rfqId, Long tenantId);
/** 多维比价 + 聚类推荐,返回完整结果 */
BizComparisonVO.CompareResult compareRfq(Long rfqId, Long tenantId);
}

View File

@@ -5,18 +5,41 @@ import com.ruoyi.system.mapper.bid.BizComparisonMapper;
import com.ruoyi.system.service.bid.IBizComparisonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
/**
* 智慧比价服务
*
* 评分维度权重:
* 价格 40 % — 最低价=100, 其他按比例递减
* 交期 25 % — 最短天数=100, 其他按比例递减
* 质量 20 % — 历史 quality_score (1-5) 归一化到 0-100
* 服务 15 % — 历史 service_score (1-5) 归一化到 0-100
*
* 聚类算法: 对每个物料行选出综合评分最高的供应商,
* 再把相同供应商的推荐行合并为一个采购方案。
*/
@Service
public class BizComparisonServiceImpl implements IBizComparisonService {
@Autowired private BizComparisonMapper mapper;
@Override
public List<BizComparisonVO> compareRfq(Long rfqId, Long tenantId) {
List<Map<String, Object>> rows = mapper.selectComparisonData(rfqId, tenantId);
Map<Long, BizComparisonVO> itemMap = new LinkedHashMap<>();
private static final double W_PRICE = 0.40;
private static final double W_DELIVERY = 0.25;
private static final double W_QUALITY = 0.20;
private static final double W_SERVICE = 0.15;
@Override
public BizComparisonVO.CompareResult compareRfq(Long rfqId, Long tenantId) {
List<Map<String, Object>> rows = mapper.selectComparisonData(rfqId);
Map<String, Object> rfqInfo = mapper.selectRfqInfo(rfqId);
// ── Step 1: 按物料行归组 ────────────────────────────────────────────
Map<Long, BizComparisonVO> itemMap = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
Long rfqItemId = toLong(row.get("rfqItemId"));
BizComparisonVO vo = itemMap.computeIfAbsent(rfqItemId, k -> {
@@ -38,27 +61,158 @@ public class BizComparisonServiceImpl implements IBizComparisonService {
sp.setQuoteNo(str(row.get("quoteNo")));
sp.setUnitPrice(toBD(row.get("unitPrice")));
sp.setTotalPrice(toBD(row.get("totalPrice")));
sp.setDeliveryDays(row.get("deliveryDays") == null ? 0 : ((Number) row.get("deliveryDays")).intValue());
sp.setDeliveryDays(row.get("deliveryDays") == null ? 30
: ((Number) row.get("deliveryDays")).intValue());
sp.setEvalAvgScore(toDouble(row.get("avgQuality")));
sp.setHistoryCount(row.get("historyCount") == null ? 0
: ((Number) row.get("historyCount")).intValue());
// Store avgService temporarily in qualityScore slot; will be replaced after normalization
sp.setQualityScore(toDouble(row.get("avgQuality")));
sp.setServiceScore(toDouble(row.get("avgService")));
vo.getPrices().add(sp);
}
}
// mark lowest price per item
// ── Step 2: 多维归一化评分 ───────────────────────────────────────────
for (BizComparisonVO vo : itemMap.values()) {
BigDecimal min = vo.getPrices().stream()
List<BizComparisonVO.SupplierPrice> prices = vo.getPrices();
if (prices.isEmpty()) continue;
// min price & min delivery
BigDecimal minPrice = prices.stream()
.filter(p -> p.getUnitPrice() != null)
.map(BizComparisonVO.SupplierPrice::getUnitPrice)
.min(BigDecimal::compareTo).orElse(null);
if (min != null) {
for (BizComparisonVO.SupplierPrice p : vo.getPrices()) {
p.setLowestPrice(min.compareTo(p.getUnitPrice()) == 0);
.min(BigDecimal::compareTo).orElse(BigDecimal.ONE);
int minDays = prices.stream()
.mapToInt(p -> p.getDeliveryDays() == null ? 30 : p.getDeliveryDays())
.min().orElse(1);
if (minDays <= 0) minDays = 1;
for (BizComparisonVO.SupplierPrice sp : prices) {
// 价格得分
double priceS = 0;
if (sp.getUnitPrice() != null && sp.getUnitPrice().compareTo(BigDecimal.ZERO) > 0) {
priceS = minPrice.doubleValue() / sp.getUnitPrice().doubleValue() * 100.0;
}
sp.setPriceScore(round2(priceS));
// 交期得分 (最短=100, 1.5倍最短=50)
int days = sp.getDeliveryDays() == null ? 30 : sp.getDeliveryDays();
double deliveryS = days <= 0 ? 100 : Math.min(100.0, (double) minDays / days * 100.0);
sp.setDeliveryScore(round2(deliveryS));
// 质量/服务得分 (历史均分 1-5 → 0-100; 无历史给 60 分中性分)
double rawQ = sp.getQualityScore(); // avgQuality stored temporarily
double rawS = sp.getServiceScore(); // avgService stored temporarily
double qualityS = sp.getHistoryCount() == 0 ? 60.0 : (rawQ - 1) / 4.0 * 100.0;
double serviceS = sp.getHistoryCount() == 0 ? 60.0 : (rawS - 1) / 4.0 * 100.0;
sp.setQualityScore(round2(qualityS));
sp.setServiceScore(round2(serviceS));
// 综合得分
double composite = W_PRICE * priceS + W_DELIVERY * deliveryS
+ W_QUALITY * qualityS + W_SERVICE * serviceS;
sp.setCompositeScore(round2(composite));
// 最低价标记
sp.setLowestPrice(minPrice.compareTo(sp.getUnitPrice()) == 0);
}
// ── 排序 + 标记徽章 ──
prices.sort(Comparator.comparingDouble(BizComparisonVO.SupplierPrice::getCompositeScore).reversed());
if (!prices.isEmpty()) {
BizComparisonVO.SupplierPrice best = prices.get(0);
best.setRankBadge("综合最优");
}
// 价格最低标记
prices.stream().filter(BizComparisonVO.SupplierPrice::isLowestPrice)
.findFirst().ifPresent(p -> {
if (p.getRankBadge() == null) p.setRankBadge("价格最低");
});
// 交期最快标记
prices.stream().min(Comparator.comparingInt(p -> p.getDeliveryDays() == null ? 999 : p.getDeliveryDays()))
.ifPresent(p -> {
if (p.getRankBadge() == null) p.setRankBadge("交期最快");
});
}
return new ArrayList<>(itemMap.values());
List<BizComparisonVO> items = new ArrayList<>(itemMap.values());
// ── Step 3: 聚类 — 每个物料选最优供应商,按供应商合并为采购方案 ────────
// planMap: supplierId -> SupplierPlan
Map<Long, BizComparisonVO.SupplierPlan> planMap = new LinkedHashMap<>();
for (BizComparisonVO vo : items) {
if (vo.getPrices() == null || vo.getPrices().isEmpty()) continue;
// 选综合分最高的供应商
BizComparisonVO.SupplierPrice best = vo.getPrices().get(0); // already sorted desc
if (best.getSupplierId() == null) continue;
BizComparisonVO.SupplierPlan plan = planMap.computeIfAbsent(best.getSupplierId(), k -> {
BizComparisonVO.SupplierPlan p = new BizComparisonVO.SupplierPlan();
p.setSupplierId(best.getSupplierId());
p.setSupplierName(best.getSupplierName());
p.setItems(new ArrayList<>());
p.setTotalAmount(BigDecimal.ZERO);
return p;
});
BizComparisonVO.SupplierPlan.PlanItem pi = new BizComparisonVO.SupplierPlan.PlanItem();
pi.setMaterialName(vo.getMaterialName());
pi.setSpec(vo.getSpec());
pi.setUnit(vo.getUnit());
pi.setQuantity(vo.getQuantity());
pi.setUnitPrice(best.getUnitPrice());
pi.setDeliveryDays(best.getDeliveryDays());
pi.setCompositeScore(best.getCompositeScore());
pi.setRankBadge(best.getRankBadge());
BigDecimal lineTotal = best.getTotalPrice() != null ? best.getTotalPrice()
: (vo.getQuantity() != null && best.getUnitPrice() != null
? vo.getQuantity().multiply(best.getUnitPrice()).setScale(2, RoundingMode.HALF_UP)
: BigDecimal.ZERO);
pi.setTotalPrice(lineTotal);
plan.getItems().add(pi);
plan.setTotalAmount(plan.getTotalAmount().add(lineTotal));
}
// 计算每个方案的平均综合分 + 聚类原因
for (BizComparisonVO.SupplierPlan plan : planMap.values()) {
double avg = plan.getItems().stream()
.mapToDouble(BizComparisonVO.SupplierPlan.PlanItem::getCompositeScore)
.average().orElse(0);
plan.setAvgCompositeScore(round2(avg));
long priceWins = plan.getItems().stream()
.filter(i -> "价格最低".equals(i.getRankBadge()) || "综合最优".equals(i.getRankBadge())).count();
String reason;
if (avg >= 80) reason = "综合评分优秀,推荐优先采购";
else if (avg >= 65) reason = "综合评分良好,性价比较优";
else reason = "部分物料为唯一报价供应商";
if (priceWins > 0) reason += "" + priceWins + "项价格具有竞争力";
plan.setClusterReason(reason);
}
List<BizComparisonVO.SupplierPlan> plans = new ArrayList<>(planMap.values());
plans.sort(Comparator.comparingDouble(BizComparisonVO.SupplierPlan::getAvgCompositeScore).reversed());
// ── 组装最终结果 ────────────────────────────────────────────────────
BizComparisonVO.CompareResult result = new BizComparisonVO.CompareResult();
result.setItems(items);
result.setRecommendedPlans(plans);
if (rfqInfo != null) {
result.setRfqNo(str(rfqInfo.get("rfqNo")));
result.setRfqTitle(str(rfqInfo.get("rfqTitle")));
}
return result;
}
// ── Utilities ─────────────────────────────────────────────────────────────
private Long toLong(Object v) { return v == null ? null : ((Number)v).longValue(); }
private BigDecimal toBD(Object v) { return v == null ? null : new BigDecimal(v.toString()); }
private String str(Object v) { return v == null ? "" : v.toString(); }
private double toDouble(Object v) { return v == null ? 3.0 : ((Number)v).doubleValue(); }
private double round2(double v) { return Math.round(v * 100) / 100.0; }
}

View File

@@ -1,25 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.bid.BizComparisonMapper">
<!-- 核心比价数据:物料行 + 各供应商报价 + 历史评价均分 -->
<select id="selectComparisonData" resultType="java.util.HashMap">
SELECT
ri.item_id AS rfqItemId,
ri.material_name AS materialName,
ri.spec AS spec,
ri.unit AS unit,
ri.quantity AS quantity,
qi.unit_price AS unitPrice,
qi.total_price AS totalPrice,
qi.delivery_days AS deliveryDays,
q.quotation_id AS quotationId,
q.quote_no AS quoteNo,
q.supplier_id AS supplierId,
s.supplier_name AS supplierName
ri.item_id AS rfqItemId,
ri.material_name AS materialName,
ri.spec AS spec,
ri.unit AS unit,
ri.quantity AS quantity,
qi.unit_price AS unitPrice,
qi.total_price AS totalPrice,
qi.delivery_days AS deliveryDays,
q.quotation_id AS quotationId,
q.quote_no AS quoteNo,
q.supplier_id AS supplierId,
s.supplier_name AS supplierName,
s.contact AS contact,
s.phone AS phone,
COALESCE(ev.avg_quality, 3.0) AS avgQuality,
COALESCE(ev.avg_service, 3.0) AS avgService,
COALESCE(ev.eval_count, 0) AS historyCount
FROM biz_rfq_item ri
LEFT JOIN biz_quotation_item qi ON qi.rfq_item_id = ri.item_id
LEFT JOIN biz_quotation q ON q.quotation_id = qi.quotation_id AND q.status = 'submitted'
LEFT JOIN biz_supplier s ON s.supplier_id = q.supplier_id
LEFT JOIN biz_quotation_item qi ON qi.rfq_item_id = ri.item_id
LEFT JOIN biz_quotation q ON q.quotation_id = qi.quotation_id
AND q.status IN ('submitted','accepted')
LEFT JOIN biz_supplier s ON s.supplier_id = q.supplier_id
LEFT JOIN (
SELECT supplier_id,
AVG(quality_score) AS avg_quality,
AVG(service_score) AS avg_service,
COUNT(*) AS eval_count
FROM biz_supplier_evaluation
GROUP BY supplier_id
) ev ON ev.supplier_id = q.supplier_id
WHERE ri.rfq_id = #{rfqId}
ORDER BY ri.item_id, qi.unit_price
ORDER BY ri.item_id, qi.unit_price ASC
</select>
<!-- RFQ基本信息 -->
<select id="selectRfqInfo" resultType="java.util.HashMap">
SELECT rfq_no AS rfqNo, rfq_title AS rfqTitle
FROM biz_rfq WHERE rfq_id = #{rfqId}
</select>
</mapper>

View File

@@ -1,77 +1,495 @@
<template>
<div class="app-container">
<div class="filter-bar">
<div class="app-container comparison-page">
<!-- 顶部选择栏 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" size="small">
<el-form-item label="选择RFQ">
<el-select v-model="selectedRfqId" placeholder="请选择要比价的RFQ" filterable style="width:320px">
<el-option v-for="r in rfqOptions" :key="r.rfqId" :label="r.rfqNo+' — '+r.rfqTitle" :value="r.rfqId" />
<el-form-item label="选择询价单">
<el-select v-model="selectedRfqId" placeholder="请选择要比价的RFQ" filterable style="width:360px" @change="resetResult">
<el-option v-for="r in rfqOptions" :key="r.rfqId" :label="r.rfqNo + ' — ' + r.rfqTitle" :value="r.rfqId" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-data-analysis" @click="doCompare" :loading="loading">开始比价</el-button>
<el-button type="primary" icon="el-icon-data-analysis" @click="doCompare" :loading="loading">开始智慧比价</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 空状态 -->
<div v-if="!result && !loading" class="empty-state">
<i class="el-icon-data-analysis"></i>
<p>选择询价单点击开始智慧比价查看多维度分析与推荐方案</p>
</div>
<div v-if="result.length === 0 && !loading" class="empty-tip">
<i class="el-icon-data-analysis" style="font-size:48px;color:#ddd"></i>
<p>请选择RFQ并点击"开始比价"</p>
</div>
<div v-if="result">
<el-tabs v-model="activeTab" type="border-card" class="main-tabs">
<div v-for="(item, idx) in result" :key="item.rfqItemId" style="margin-bottom:24px">
<div class="compare-header">
<span class="item-no">物料 {{ idx+1 }}</span>
<strong>{{ item.materialName }}</strong>
<span v-if="item.spec" style="color:#909399;margin-left:8px">{{ item.spec }}</span>
<span style="margin-left:16px;color:#606266">数量: {{ item.quantity }} {{ item.unit }}</span>
</div>
<el-table :data="item.prices || []" border size="small">
<el-table-column label="供应商" prop="supplierName" min-width="140">
<template slot-scope="scope">
<i class="el-icon-star-on" style="color:#f7ba2a;margin-right:4px" v-if="scope.row.lowestPrice"></i>
{{ scope.row.supplierName }}
</template>
</el-table-column>
<el-table-column label="报价单号" prop="quoteNo" width="140" />
<el-table-column label="单价" prop="unitPrice" width="120" align="right">
<template slot-scope="scope">
<span :style="scope.row.lowestPrice ? 'color:#67C23A;font-weight:bold' : ''">
¥{{ scope.row.unitPrice }}
</span>
<el-tag v-if="scope.row.lowestPrice" type="success" size="mini" style="margin-left:4px">最低</el-tag>
</template>
</el-table-column>
<el-table-column label="总价" prop="totalPrice" width="130" align="right">
<template slot-scope="scope">¥{{ scope.row.totalPrice }}</template>
</el-table-column>
<el-table-column label="交货期(天)" prop="deliveryDays" width="100" align="center" />
</el-table>
<div v-if="!item.prices || item.prices.length === 0" style="padding:12px;color:#909399;background:#fafafa;border:1px solid #eee">暂无供应商报价</div>
<!-- Tab 1: 维度比价 -->
<el-tab-pane label="📊 多维度比价" name="compare">
<div v-for="(item, idx) in result.items" :key="item.rfqItemId" class="item-block">
<div class="item-header">
<span class="item-badge">物料 {{ idx + 1 }}</span>
<strong class="item-name">{{ item.materialName }}</strong>
<span class="item-spec" v-if="item.spec">{{ item.spec }}</span>
<span class="item-qty">需求数量{{ item.quantity }} {{ item.unit }}</span>
</div>
<div v-if="!item.prices || !item.prices.length" class="no-quote">暂无供应商报价</div>
<div v-else>
<!-- 综合排名卡片 -->
<div class="supplier-cards">
<div v-for="(sp, si) in item.prices" :key="sp.supplierId"
class="supplier-card" :class="{ 'best-card': si === 0 }">
<div class="card-rank">
<span class="rank-num" :class="'rank-' + (si+1)">{{ si + 1 }}</span>
<el-tag v-if="sp.rankBadge" size="mini" :type="badgeType(sp.rankBadge)" class="rank-badge">{{ sp.rankBadge }}</el-tag>
</div>
<div class="card-supplier">{{ sp.supplierName }}</div>
<div class="card-score-big" :class="scoreClass(sp.compositeScore)">
{{ sp.compositeScore.toFixed(1) }}
<span class="score-unit"></span>
</div>
<div class="card-price">¥{{ sp.unitPrice }} / {{ item.unit }}</div>
<!-- 四维进度条 -->
<div class="dim-bars">
<div class="dim-row">
<span class="dim-label">价格</span>
<el-progress :percentage="sp.priceScore" :stroke-width="6" :color="dimColor(sp.priceScore)" :show-text="false" class="dim-bar" />
<span class="dim-val">{{ sp.priceScore.toFixed(0) }}</span>
</div>
<div class="dim-row">
<span class="dim-label">交期</span>
<el-progress :percentage="sp.deliveryScore" :stroke-width="6" :color="dimColor(sp.deliveryScore)" :show-text="false" class="dim-bar" />
<span class="dim-val">{{ sp.deliveryScore.toFixed(0) }}</span>
</div>
<div class="dim-row">
<span class="dim-label">质量</span>
<el-progress :percentage="sp.qualityScore" :stroke-width="6" :color="dimColor(sp.qualityScore)" :show-text="false" class="dim-bar" />
<span class="dim-val">{{ sp.qualityScore.toFixed(0) }}</span>
</div>
<div class="dim-row">
<span class="dim-label">服务</span>
<el-progress :percentage="sp.serviceScore" :stroke-width="6" :color="dimColor(sp.serviceScore)" :show-text="false" class="dim-bar" />
<span class="dim-val">{{ sp.serviceScore.toFixed(0) }}</span>
</div>
</div>
<div class="card-meta">
交货 {{ sp.deliveryDays }}
<span v-if="sp.historyCount > 0" style="margin-left:8px">· 历史 {{ sp.historyCount }} </span>
</div>
</div>
</div>
<!-- 明细对比表 -->
<el-table :data="item.prices" border size="small" class="detail-table">
<el-table-column label="供应商" prop="supplierName" min-width="140" />
<el-table-column label="报价单号" prop="quoteNo" width="130" />
<el-table-column label="单价(元)" prop="unitPrice" width="110" align="right">
<template slot-scope="s">
<span :class="s.row.lowestPrice ? 'price-low' : ''">¥{{ s.row.unitPrice }}</span>
<el-tag v-if="s.row.lowestPrice" type="success" size="mini" style="margin-left:4px">最低</el-tag>
</template>
</el-table-column>
<el-table-column label="总价(元)" width="120" align="right">
<template slot-scope="s">¥{{ s.row.totalPrice }}</template>
</el-table-column>
<el-table-column label="交期(天)" prop="deliveryDays" width="85" align="center" />
<el-table-column label="价格分" width="80" align="center">
<template slot-scope="s"><span :class="scoreClass(s.row.priceScore)">{{ s.row.priceScore }}</span></template>
</el-table-column>
<el-table-column label="交期分" width="80" align="center">
<template slot-scope="s"><span :class="scoreClass(s.row.deliveryScore)">{{ s.row.deliveryScore }}</span></template>
</el-table-column>
<el-table-column label="质量分" width="80" align="center">
<template slot-scope="s"><span :class="scoreClass(s.row.qualityScore)">{{ s.row.qualityScore }}</span></template>
</el-table-column>
<el-table-column label="服务分" width="80" align="center">
<template slot-scope="s"><span :class="scoreClass(s.row.serviceScore)">{{ s.row.serviceScore }}</span></template>
</el-table-column>
<el-table-column label="综合分" width="90" align="center">
<template slot-scope="s">
<strong :class="scoreClass(s.row.compositeScore)">{{ s.row.compositeScore }}</strong>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<!-- Tab 2: 推荐采购方案 -->
<el-tab-pane name="plan">
<span slot="label">🎯 智能推荐方案 <el-badge :value="result.recommendedPlans.length" type="primary" /></span>
<el-alert type="info" :closable="false" show-icon style="margin-bottom:20px">
<template slot="title">
算法说明综合评分 = 价格(40%) + 交期(25%) + 历史质量(20%) + 历史服务(15%)
对每个物料选出综合分最高的供应商相同供应商的物料合并为一份采购方案
</template>
</el-alert>
<div v-if="!result.recommendedPlans.length" class="no-quote" style="margin:40px auto">
暂无可用报价无法生成推荐方案
</div>
<div v-for="(plan, pi) in result.recommendedPlans" :key="plan.supplierId" class="plan-card">
<div class="plan-header">
<div class="plan-title">
<span class="plan-rank">方案 {{ pi + 1 }}</span>
<strong class="plan-supplier">{{ plan.supplierName }}</strong>
<el-tag type="success" size="small" style="margin-left:12px">
综合评分 {{ plan.avgCompositeScore.toFixed(1) }}
</el-tag>
</div>
<div class="plan-actions">
<span class="plan-total">合计<strong>¥{{ plan.totalAmount.toFixed ? plan.totalAmount.toFixed(2) : plan.totalAmount }}</strong></span>
<el-button type="primary" size="small" icon="el-icon-download"
:loading="pdfLoading[plan.supplierId]" @click="exportPlanPdf(plan, pi)">
导出 PDF
</el-button>
</div>
</div>
<div class="plan-reason">
<i class="el-icon-info" style="color:#409EFF;margin-right:4px"></i>{{ plan.clusterReason }}
</div>
<!-- 方案物料表 -->
<el-table :data="plan.items" border size="small" class="plan-table">
<el-table-column type="index" width="46" label="#" />
<el-table-column label="物料名称" prop="materialName" min-width="140" />
<el-table-column label="规格" prop="spec" min-width="120" show-overflow-tooltip />
<el-table-column label="单位" prop="unit" width="70" align="center" />
<el-table-column label="数量" prop="quantity" width="80" align="right" />
<el-table-column label="单价(元)" prop="unitPrice" width="110" align="right">
<template slot-scope="s">¥{{ s.row.unitPrice }}</template>
</el-table-column>
<el-table-column label="金额(元)" width="120" align="right">
<template slot-scope="s">
<strong style="color:#409EFF">¥{{ s.row.totalPrice }}</strong>
</template>
</el-table-column>
<el-table-column label="交期(天)" prop="deliveryDays" width="85" align="center" />
<el-table-column label="综合分" width="80" align="center">
<template slot-scope="s">
<strong :class="scoreClass(s.row.compositeScore)">{{ s.row.compositeScore }}</strong>
</template>
</el-table-column>
<el-table-column label="推荐标签" width="90" align="center">
<template slot-scope="s">
<el-tag v-if="s.row.rankBadge" size="mini" :type="badgeType(s.row.rankBadge)">{{ s.row.rankBadge }}</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 隐藏 PDF 渲染区 -->
<div :id="'plan-pdf-' + plan.supplierId" class="pdf-area" style="position:absolute;left:-9999px;top:0;width:794px">
<div class="pdf-header">
<img :src="logoSrc" class="pdf-logo" />
<div class="pdf-header-text">
<div class="pdf-company">福安德综合报价系统</div>
<div class="pdf-doc-type">智慧比价推荐采购方案</div>
</div>
<div class="pdf-header-meta">
<div>方案编号方案{{ pi + 1 }}</div>
<div>生成日期{{ today }}</div>
</div>
</div>
<div class="pdf-divider"></div>
<table class="pdf-meta-table">
<tr>
<td class="meta-label">询价单</td>
<td class="meta-val" colspan="3">{{ result.rfqNo }} {{ result.rfqTitle }}</td>
</tr>
<tr>
<td class="meta-label">推荐供应商</td><td class="meta-val">{{ plan.supplierName }}</td>
<td class="meta-label">综合评分</td><td class="meta-val score-good">{{ plan.avgCompositeScore.toFixed(1) }} / 100</td>
</tr>
<tr>
<td class="meta-label">合计金额</td>
<td class="meta-val amount-big" colspan="3">¥{{ plan.totalAmount.toFixed ? plan.totalAmount.toFixed(2) : plan.totalAmount }}</td>
</tr>
<tr>
<td class="meta-label">推荐依据</td>
<td class="meta-val" colspan="3">{{ plan.clusterReason }}</td>
</tr>
</table>
<div class="pdf-score-legend">
<strong>评分权重说明</strong>
价格占比 40% &nbsp;|&nbsp; 交期占比 25% &nbsp;|&nbsp; 历史质量占比 20% &nbsp;|&nbsp; 服务水平占比 15%
</div>
<div class="pdf-section-title">采购物料明细</div>
<table class="pdf-items-table">
<thead>
<tr>
<th>#</th><th>物料名称</th><th>规格型号</th><th>单位</th>
<th>数量</th><th>单价()</th><th>金额()</th><th>交货期()</th><th>综合评分</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, ii) in plan.items" :key="ii">
<td>{{ ii + 1 }}</td>
<td>{{ item.materialName }}</td>
<td>{{ item.spec || '-' }}</td>
<td>{{ item.unit }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unitPrice }}</td>
<td class="amount-cell">{{ item.totalPrice }}</td>
<td>{{ item.deliveryDays }}</td>
<td :class="'score-cell ' + scorePdfClass(item.compositeScore)">{{ item.compositeScore }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="6" style="text-align:right;font-weight:bold;padding:10px 8px">合计</td>
<td class="amount-cell total-cell" colspan="3">¥{{ plan.totalAmount.toFixed ? plan.totalAmount.toFixed(2) : plan.totalAmount }}</td>
</tr>
</tfoot>
</table>
<div class="pdf-footer">本方案由福安德智慧报价系统自动生成仅供参考 · 生成时间{{ new Date().toLocaleString('zh-CN') }}</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script>
import { compareRfq } from "@/api/bid/comparison";
import { listRfq } from "@/api/bid/rfq";
import logoImg from "@/assets/logo/logo.png";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
export default {
name: "Comparison",
data() {
return { selectedRfqId: null, loading: false, result: [], rfqOptions: [] };
return {
selectedRfqId: null,
loading: false,
result: null,
activeTab: "compare",
rfqOptions: [],
logoSrc: logoImg,
pdfLoading: {},
today: new Date().toLocaleDateString("zh-CN")
};
},
created() {
listRfq({ pageSize: 200, status: "published" }).then(r => { this.rfqOptions = r.rows || []; });
listRfq({ pageSize: 200 }).then(r => { this.rfqOptions = r.rows || []; });
},
methods: {
resetResult() { this.result = null; this.activeTab = "compare"; },
doCompare() {
if (!this.selectedRfqId) { this.$message.warning("请先选择RFQ"); return; }
this.loading = true;
compareRfq(this.selectedRfqId).then(r => { this.result = r.data || []; this.loading = false; }).catch(() => { this.loading = false; });
compareRfq(this.selectedRfqId).then(r => {
this.result = r.data;
this.loading = false;
this.activeTab = "compare";
}).catch(() => { this.loading = false; });
},
async exportPlanPdf(plan, idx) {
this.$set(this.pdfLoading, plan.supplierId, true);
// wait a tick so DOM updates
await this.$nextTick();
await new Promise(resolve => setTimeout(resolve, 200));
try {
const el = document.getElementById("plan-pdf-" + plan.supplierId);
// temporarily show it
const origStyle = el.style.cssText;
el.style.cssText = "position:absolute;left:-9999px;top:0;width:794px;background:#fff";
const canvas = await html2canvas(el, { scale: 2, useCORS: true, backgroundColor: "#ffffff", width: 794 });
el.style.cssText = origStyle;
const imgData = canvas.toDataURL("image/png");
const pdf = new jsPDF({ orientation: "p", unit: "mm", format: "a4" });
const pageW = pdf.internal.pageSize.getWidth();
const pageH = pdf.internal.pageSize.getHeight();
const imgH = (canvas.height * pageW) / canvas.width;
let yPos = 0;
let remaining = imgH;
let firstPage = true;
while (remaining > 0) {
if (!firstPage) pdf.addPage();
pdf.addImage(imgData, "PNG", 0, yPos, pageW, imgH);
yPos -= pageH;
remaining -= pageH;
firstPage = false;
}
const filename = "推荐方案_" + plan.supplierName + "_方案" + (idx + 1) + ".pdf";
pdf.save(filename);
this.$message.success("PDF 已导出:" + filename);
} catch(e) {
this.$message.error("PDF 导出失败:" + e.message);
} finally {
this.$set(this.pdfLoading, plan.supplierId, false);
}
},
badgeType(badge) {
if (badge === "综合最优") return "success";
if (badge === "价格最低") return "warning";
if (badge === "交期最快") return "primary";
return "info";
},
scoreClass(score) {
if (score >= 80) return "score-high";
if (score >= 60) return "score-mid";
return "score-low";
},
scorePdfClass(score) {
if (score >= 80) return "score-good";
if (score >= 60) return "score-ok";
return "score-weak";
},
dimColor(v) {
if (v >= 80) return "#67c23a";
if (v >= 60) return "#e6a23c";
return "#f56c6c";
}
}
};
</script>
<style scoped>
.compare-header { background:#f5f7fa; padding:10px 16px; border-left:4px solid #409EFF; margin-bottom:8px; border-radius:2px; }
.item-no { background:#409EFF; color:#fff; padding:2px 8px; border-radius:10px; font-size:12px; margin-right:8px; }
.empty-tip { text-align:center; padding:80px; color:#909399; }
<style lang="scss" scoped>
.comparison-page { padding: 0; }
.filter-card { margin-bottom: 20px; ::v-deep .el-card__body { padding: 16px 20px; } }
.empty-state {
text-align: center; padding: 100px 40px; color: #c0c4cc;
i { font-size: 56px; display: block; margin-bottom: 16px; }
p { font-size: 14px; }
}
.main-tabs { ::v-deep .el-tabs__content { padding: 20px; } }
/* ── 物料块 ── */
.item-block { margin-bottom: 32px; }
.item-header {
background: linear-gradient(90deg, #1171c4, #22a4ff);
color: #fff; padding: 10px 16px; border-radius: 6px 6px 0 0;
display: flex; align-items: center; gap: 10px;
}
.item-badge {
background: rgba(255,255,255,0.25); padding: 2px 10px;
border-radius: 12px; font-size: 12px;
}
.item-name { font-size: 15px; font-weight: 700; }
.item-spec { color: rgba(255,255,255,0.8); font-size: 13px; }
.item-qty { margin-left: auto; font-size: 12px; color: rgba(255,255,255,0.8); }
.no-quote {
padding: 20px; color: #909399; background: #fafafa;
border: 1px dashed #e4e7ed; border-radius: 4px; text-align: center;
}
/* ── 供应商排名卡片 ── */
.supplier-cards {
display: flex; gap: 12px; padding: 16px 0; overflow-x: auto;
}
.supplier-card {
flex: 0 0 200px; border: 1px solid #e4e7ed; border-radius: 8px;
padding: 14px; background: #fff; transition: box-shadow 0.2s;
&:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
&.best-card { border-color: #67c23a; box-shadow: 0 2px 8px rgba(103,194,58,0.2); }
}
.card-rank { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.rank-num {
width: 22px; height: 22px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 12px; font-weight: 700;
&.rank-1 { background: #f7ba2a; color: #fff; }
&.rank-2 { background: #c0c4cc; color: #fff; }
&.rank-3 { background: #a57c52; color: #fff; }
}
.rank-badge { flex-shrink: 0; }
.card-supplier { font-size: 13px; font-weight: 600; color: #303133; margin-bottom: 6px; }
.card-score-big {
font-size: 28px; font-weight: 700; line-height: 1; margin: 8px 0;
&.score-high { color: #67c23a; }
&.score-mid { color: #e6a23c; }
&.score-low { color: #f56c6c; }
.score-unit { font-size: 13px; font-weight: 400; margin-left: 2px; }
}
.card-price { font-size: 13px; color: #409EFF; font-weight: 600; margin-bottom: 10px; }
.dim-bars { display: flex; flex-direction: column; gap: 4px; }
.dim-row { display: flex; align-items: center; gap: 6px; }
.dim-label { font-size: 11px; color: #909399; width: 24px; flex-shrink: 0; }
.dim-bar { flex: 1; }
.dim-val { font-size: 11px; color: #606266; width: 24px; text-align: right; flex-shrink: 0; }
.card-meta { font-size: 11px; color: #c0c4cc; margin-top: 8px; }
.detail-table { margin-top: 8px; }
/* ── 分数颜色 ── */
.score-high { color: #67c23a; font-weight: 700; }
.score-mid { color: #e6a23c; font-weight: 600; }
.score-low { color: #f56c6c; }
.price-low { color: #67c23a; font-weight: 700; }
/* ── 推荐方案卡片 ── */
.plan-card {
border: 1px solid #e4e7ed; border-radius: 8px; margin-bottom: 24px;
overflow: hidden; background: #fff; position: relative;
}
.plan-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; background: linear-gradient(90deg, #f0f7ff, #fff);
border-bottom: 1px solid #e4e7ed;
}
.plan-title { display: flex; align-items: center; gap: 10px; }
.plan-rank {
background: #1171c4; color: #fff; padding: 3px 10px;
border-radius: 12px; font-size: 12px;
}
.plan-supplier { font-size: 16px; font-weight: 700; color: #1a2c4e; }
.plan-actions { display: flex; align-items: center; gap: 16px; }
.plan-total { font-size: 14px; color: #606266; strong { font-size: 18px; color: #409EFF; } }
.plan-reason { padding: 8px 20px; font-size: 13px; color: #606266; background: #f9fbff; border-bottom: 1px solid #f0f2f5; }
.plan-table { border-radius: 0; }
/* ── PDF 渲染区域 ── */
.pdf-area {
padding: 28px;
background: #fff;
font-family: "Microsoft YaHei", "Noto Sans SC", Arial, sans-serif;
font-size: 13px; color: #222;
}
.pdf-header { display: flex; align-items: flex-start; padding-bottom: 12px; }
.pdf-logo { width: 52px; height: 52px; object-fit: contain; margin-right: 14px; }
.pdf-header-text { flex: 1; }
.pdf-company { font-size: 22px; font-weight: 700; color: #1171c4; letter-spacing: 1px; }
.pdf-doc-type { font-size: 13px; color: #666; margin-top: 2px; }
.pdf-header-meta { font-size: 12px; color: #888; text-align: right; }
.pdf-divider { border-top: 2px solid #1171c4; margin: 0 0 16px; }
.pdf-meta-table {
width: 100%; border-collapse: collapse; margin-bottom: 16px;
td { padding: 7px 10px; border: 1px solid #e4e7ed; }
}
.meta-label { background: #f5f7fa; color: #606266; font-weight: 600; width: 90px; }
.meta-val { color: #303133; }
.amount-big { color: #409EFF; font-weight: 700; font-size: 16px; }
.score-good { color: #67c23a; font-weight: 700; }
.score-ok { color: #e6a23c; font-weight: 600; }
.score-weak { color: #f56c6c; }
.pdf-score-legend {
background: #f0f7ff; border: 1px solid #c6e2ff; border-radius: 4px;
padding: 8px 14px; font-size: 12px; color: #606266; margin-bottom: 16px;
}
.pdf-section-title {
font-size: 14px; font-weight: 700; color: #1a2c4e;
margin: 0 0 10px; padding-left: 8px; border-left: 4px solid #1171c4;
}
.pdf-items-table {
width: 100%; border-collapse: collapse; margin-bottom: 20px;
th { background: #1171c4; color: #fff; padding: 8px 8px; text-align: center; font-weight: 600; font-size: 12px; }
td { border: 1px solid #e4e7ed; padding: 7px 8px; text-align: center; font-size: 12px; }
tbody tr:nth-child(even) td { background: #f9fbff; }
}
.amount-cell { color: #409EFF; font-weight: 600; }
.total-cell { font-size: 14px; background: #f0f7ff !important; font-weight: 700; }
.score-cell { font-weight: 700; }
.pdf-footer { text-align: center; font-size: 11px; color: #aaa; margin-top: 16px; border-top: 1px solid #f0f2f5; padding-top: 12px; }
</style>