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

@@ -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; }
}