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:
@@ -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) {
|
||||
|
||||
@@ -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;}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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% | 交期占比 25% | 历史质量占比 20% | 服务水平占比 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>
|
||||
|
||||
Reference in New Issue
Block a user