diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizComparisonController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizComparisonController.java index 04fa9435..08e4536c 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizComparisonController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizComparisonController.java @@ -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) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/BizComparisonVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/BizComparisonVO.java index f433bf90..f6fb6f33 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/BizComparisonVO.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/BizComparisonVO.java @@ -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 prices; + private List 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 getPrices() { return prices; } - public void setPrices(List 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 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 getItems(){return items;} + public void setItems(List v){items=v;} + } + + // ---------- 最终返回:包裹物料列表 + 推荐方案 ---------- + public static class CompareResult { + private List items; + private List recommendedPlans; + private String rfqTitle; + private String rfqNo; + + public List getItems(){return items;} + public void setItems(List v){items=v;} + public List getRecommendedPlans(){return recommendedPlans;} + public void setRecommendedPlans(List 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 getPrices(){return prices;} + public void setPrices(List v){prices=v;} } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizComparisonMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizComparisonMapper.java index a023c877..82ec96e9 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizComparisonMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizComparisonMapper.java @@ -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> selectComparisonData(@Param("rfqId") Long rfqId, @Param("tenantId") Long tenantId); + List> selectComparisonData(@Param("rfqId") Long rfqId); + Map selectRfqInfo(@Param("rfqId") Long rfqId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizComparisonService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizComparisonService.java index f85881dd..762c0501 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizComparisonService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizComparisonService.java @@ -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 compareRfq(Long rfqId, Long tenantId); + /** 多维比价 + 聚类推荐,返回完整结果 */ + BizComparisonVO.CompareResult compareRfq(Long rfqId, Long tenantId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizComparisonServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizComparisonServiceImpl.java index 1791e589..5bfea4da 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizComparisonServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizComparisonServiceImpl.java @@ -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 compareRfq(Long rfqId, Long tenantId) { - List> rows = mapper.selectComparisonData(rfqId, tenantId); - Map 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> rows = mapper.selectComparisonData(rfqId); + Map rfqInfo = mapper.selectRfqInfo(rfqId); + + // ── Step 1: 按物料行归组 ──────────────────────────────────────────── + Map itemMap = new LinkedHashMap<>(); for (Map 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 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 items = new ArrayList<>(itemMap.values()); + + // ── Step 3: 聚类 — 每个物料选最优供应商,按供应商合并为采购方案 ──────── + // planMap: supplierId -> SupplierPlan + Map 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 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; } } diff --git a/ruoyi-system/src/main/resources/mapper/bid/BizComparisonMapper.xml b/ruoyi-system/src/main/resources/mapper/bid/BizComparisonMapper.xml index c778ff36..1b51e51d 100644 --- a/ruoyi-system/src/main/resources/mapper/bid/BizComparisonMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/bid/BizComparisonMapper.xml @@ -1,25 +1,48 @@ + + + + + + diff --git a/ruoyi-ui/src/views/bid/comparison/index.vue b/ruoyi-ui/src/views/bid/comparison/index.vue index 35af0cff..11be3f65 100644 --- a/ruoyi-ui/src/views/bid/comparison/index.vue +++ b/ruoyi-ui/src/views/bid/comparison/index.vue @@ -1,77 +1,495 @@ + -