feat(bid): 新增投标报表统计分析模块

本次提交新增了完整的投标报表统计分析功能,包括:

添加用于数据检查与菜单初始化的 SQL 脚本

实现采购概览仪表板、采购成本分析及供应商绩效报告的后端服务、Mapper、Controller 及 VO 类

添加前端 API、路由配置以及使用 ECharts 可视化图表的页面组件

为仪表板添加通用的 KPI 卡片组件
This commit is contained in:
2026-06-03 14:26:25 +08:00
parent 9db84336bc
commit ba74618bea
17 changed files with 3106 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
package com.ruoyi.system.domain.bid;
import java.math.BigDecimal;
import java.util.List;
/**
* 采购成本分析 VO
*/
public class ReportCostVO {
/** 顶部汇总 */
private CostSummary summary;
/** 月度成本趋势 */
private List<CostTrend> costTrend;
/** 品类采购分布 */
private List<CategoryDist> categoryDist;
/** RFQ 比价明细 */
private List<RfqCompareDetail> rfqDetails;
// ===== getters / setters =====
public CostSummary getSummary() { return summary; }
public void setSummary(CostSummary v) { summary = v; }
public List<CostTrend> getCostTrend() { return costTrend; }
public void setCostTrend(List<CostTrend> v) { costTrend = v; }
public List<CategoryDist> getCategoryDist() { return categoryDist; }
public void setCategoryDist(List<CategoryDist> v) { categoryDist = v; }
public List<RfqCompareDetail> getRfqDetails() { return rfqDetails; }
public void setRfqDetails(List<RfqCompareDetail> v) { rfqDetails = v; }
// ===== 内部类 =====
/** 成本汇总 */
public static class CostSummary {
private BigDecimal totalExpected;
private BigDecimal totalActual;
private BigDecimal savedAmount;
private double savedRate;
public BigDecimal getTotalExpected() { return totalExpected; }
public void setTotalExpected(BigDecimal v) { totalExpected = v; }
public BigDecimal getTotalActual() { return totalActual; }
public void setTotalActual(BigDecimal v) { totalActual = v; }
public BigDecimal getSavedAmount() { return savedAmount; }
public void setSavedAmount(BigDecimal v) { savedAmount = v; }
public double getSavedRate() { return savedRate; }
public void setSavedRate(double v) { savedRate = v; }
}
/** 月度成本趋势 */
public static class CostTrend {
private String month;
private BigDecimal expectedAmount;
private BigDecimal actualAmount;
private BigDecimal savedAmount;
public String getMonth() { return month; }
public void setMonth(String v) { month = v; }
public BigDecimal getExpectedAmount() { return expectedAmount; }
public void setExpectedAmount(BigDecimal v) { expectedAmount = v; }
public BigDecimal getActualAmount() { return actualAmount; }
public void setActualAmount(BigDecimal v) { actualAmount = v; }
public BigDecimal getSavedAmount() { return savedAmount; }
public void setSavedAmount(BigDecimal v) { savedAmount = v; }
}
/** 品类分布 */
public static class CategoryDist {
private String categoryName;
private BigDecimal amount;
private int materialCount;
private double percent;
public String getCategoryName() { return categoryName; }
public void setCategoryName(String v) { categoryName = v; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal v) { amount = v; }
public int getMaterialCount() { return materialCount; }
public void setMaterialCount(int v) { materialCount = v; }
public double getPercent() { return percent; }
public void setPercent(double v) { percent = v; }
}
/** RFQ 比价明细 */
public static class RfqCompareDetail {
private Long rfqId;
private String rfqNo;
private String rfqTitle;
private BigDecimal expectedTotal;
private BigDecimal lowestQuote;
private BigDecimal acceptedQuote;
private BigDecimal savedAmount;
private int supplierCount;
public Long getRfqId() { return rfqId; }
public void setRfqId(Long v) { rfqId = v; }
public String getRfqNo() { return rfqNo; }
public void setRfqNo(String v) { rfqNo = v; }
public String getRfqTitle() { return rfqTitle; }
public void setRfqTitle(String v) { rfqTitle = v; }
public BigDecimal getExpectedTotal() { return expectedTotal; }
public void setExpectedTotal(BigDecimal v) { expectedTotal = v; }
public BigDecimal getLowestQuote() { return lowestQuote; }
public void setLowestQuote(BigDecimal v) { lowestQuote = v; }
public BigDecimal getAcceptedQuote() { return acceptedQuote; }
public void setAcceptedQuote(BigDecimal v) { acceptedQuote = v; }
public BigDecimal getSavedAmount() { return savedAmount; }
public void setSavedAmount(BigDecimal v) { savedAmount = v; }
public int getSupplierCount() { return supplierCount; }
public void setSupplierCount(int v) { supplierCount = v; }
}
}

View File

@@ -0,0 +1,144 @@
package com.ruoyi.system.domain.bid;
import java.math.BigDecimal;
import java.util.List;
/**
* 采购总览看板 VO
*/
public class ReportDashboardVO {
/** KPI 卡片 */
private KpiCard totalPurchaseAmount;
private KpiCard totalRfqCount;
private KpiCard totalPoCount;
private KpiCard activeSupplierCount;
/** 月度采购趋势 */
private List<MonthTrend> monthlyTrend;
/** RFQ 状态分布 */
private List<StatusDist> rfqStatusDist;
/** Top 供应商排名 */
private List<SupplierRank> topSuppliers;
/** 最近动态 */
private List<RecentActivity> recentActivities;
// ===== getters / setters =====
public KpiCard getTotalPurchaseAmount() { return totalPurchaseAmount; }
public void setTotalPurchaseAmount(KpiCard v) { totalPurchaseAmount = v; }
public KpiCard getTotalRfqCount() { return totalRfqCount; }
public void setTotalRfqCount(KpiCard v) { totalRfqCount = v; }
public KpiCard getTotalPoCount() { return totalPoCount; }
public void setTotalPoCount(KpiCard v) { totalPoCount = v; }
public KpiCard getActiveSupplierCount() { return activeSupplierCount; }
public void setActiveSupplierCount(KpiCard v) { activeSupplierCount = v; }
public List<MonthTrend> getMonthlyTrend() { return monthlyTrend; }
public void setMonthlyTrend(List<MonthTrend> v) { monthlyTrend = v; }
public List<StatusDist> getRfqStatusDist() { return rfqStatusDist; }
public void setRfqStatusDist(List<StatusDist> v) { rfqStatusDist = v; }
public List<SupplierRank> getTopSuppliers() { return topSuppliers; }
public void setTopSuppliers(List<SupplierRank> v) { topSuppliers = v; }
public List<RecentActivity> getRecentActivities() { return recentActivities; }
public void setRecentActivities(List<RecentActivity> v) { recentActivities = v; }
// ===== 内部类 =====
/** KPI 指标卡 */
public static class KpiCard {
private String label;
private BigDecimal value;
private double changeRate;
private String unit;
private String trend; // up / down
public String getLabel() { return label; }
public void setLabel(String v) { label = v; }
public BigDecimal getValue() { return value; }
public void setValue(BigDecimal v) { value = v; }
public double getChangeRate() { return changeRate; }
public void setChangeRate(double v) { changeRate = v; }
public String getUnit() { return unit; }
public void setUnit(String v) { unit = v; }
public String getTrend() { return trend; }
public void setTrend(String v) { trend = v; }
}
/** 月度趋势 */
public static class MonthTrend {
private String month;
private BigDecimal amount;
private int count;
public String getMonth() { return month; }
public void setMonth(String v) { month = v; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal v) { amount = v; }
public int getCount() { return count; }
public void setCount(int v) { count = v; }
}
/** 状态分布 */
public static class StatusDist {
private String status;
private String statusLabel;
private int count;
private double percent;
public String getStatus() { return status; }
public void setStatus(String v) { status = v; }
public String getStatusLabel() { return statusLabel; }
public void setStatusLabel(String v) { statusLabel = v; }
public int getCount() { return count; }
public void setCount(int v) { count = v; }
public double getPercent() { return percent; }
public void setPercent(double v) { percent = v; }
}
/** 供应商排名 */
public static class SupplierRank {
private Long supplierId;
private String supplierName;
private BigDecimal totalAmount;
private int poCount;
private double avgScore;
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 BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal v) { totalAmount = v; }
public int getPoCount() { return poCount; }
public void setPoCount(int v) { poCount = v; }
public double getAvgScore() { return avgScore; }
public void setAvgScore(double v) { avgScore = v; }
}
/** 最近动态 */
public static class RecentActivity {
private String time;
private String type;
private String desc;
private String linkId;
public String getTime() { return time; }
public void setTime(String v) { time = v; }
public String getType() { return type; }
public void setType(String v) { type = v; }
public String getDesc() { return desc; }
public void setDesc(String v) { desc = v; }
public String getLinkId() { return linkId; }
public void setLinkId(String v) { linkId = v; }
}
}

View File

@@ -0,0 +1,133 @@
package com.ruoyi.system.domain.bid;
import java.math.BigDecimal;
import java.util.List;
/**
* 供应商绩效 VO
*/
public class ReportSupplierVO {
/** 评分排名 */
private List<SupplierScore> rankings;
/** 雷达图数据 */
private List<RadarData> radarData;
/** 中标率 */
private List<WinRateData> winRateData;
/** 异议统计 */
private List<ObjectionStat> objectionStats;
// ===== getters / setters =====
public List<SupplierScore> getRankings() { return rankings; }
public void setRankings(List<SupplierScore> v) { rankings = v; }
public List<RadarData> getRadarData() { return radarData; }
public void setRadarData(List<RadarData> v) { radarData = v; }
public List<WinRateData> getWinRateData() { return winRateData; }
public void setWinRateData(List<WinRateData> v) { winRateData = v; }
public List<ObjectionStat> getObjectionStats() { return objectionStats; }
public void setObjectionStats(List<ObjectionStat> v) { objectionStats = v; }
// ===== 内部类 =====
/** 供应商评分 */
public static class SupplierScore {
private Long supplierId;
private String supplierName;
private int evalCount;
private double qualityAvg;
private double deliveryAvg;
private double serviceAvg;
private double priceAvg;
private double totalAvg;
private int poCount;
private BigDecimal poAmount;
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 int getEvalCount() { return evalCount; }
public void setEvalCount(int v) { evalCount = v; }
public double getQualityAvg() { return qualityAvg; }
public void setQualityAvg(double v) { qualityAvg = v; }
public double getDeliveryAvg() { return deliveryAvg; }
public void setDeliveryAvg(double v) { deliveryAvg = v; }
public double getServiceAvg() { return serviceAvg; }
public void setServiceAvg(double v) { serviceAvg = v; }
public double getPriceAvg() { return priceAvg; }
public void setPriceAvg(double v) { priceAvg = v; }
public double getTotalAvg() { return totalAvg; }
public void setTotalAvg(double v) { totalAvg = v; }
public int getPoCount() { return poCount; }
public void setPoCount(int v) { poCount = v; }
public BigDecimal getPoAmount() { return poAmount; }
public void setPoAmount(BigDecimal v) { poAmount = v; }
}
/** 雷达图 */
public static class RadarData {
private String supplierName;
private double quality;
private double delivery;
private double service;
private double price;
public String getSupplierName() { return supplierName; }
public void setSupplierName(String v) { supplierName = v; }
public double getQuality() { return quality; }
public void setQuality(double v) { quality = v; }
public double getDelivery() { return delivery; }
public void setDelivery(double v) { delivery = v; }
public double getService() { return service; }
public void setService(double v) { service = v; }
public double getPrice() { return price; }
public void setPrice(double v) { price = v; }
}
/** 中标率 */
public static class WinRateData {
private Long supplierId;
private String supplierName;
private int totalQuotes;
private int winCount;
private double winRate;
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 int getTotalQuotes() { return totalQuotes; }
public void setTotalQuotes(int v) { totalQuotes = v; }
public int getWinCount() { return winCount; }
public void setWinCount(int v) { winCount = v; }
public double getWinRate() { return winRate; }
public void setWinRate(double v) { winRate = v; }
}
/** 异议统计 */
public static class ObjectionStat {
private Long supplierId;
private String supplierName;
private int objectionCount;
private int resolvedCount;
private String topReason;
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 int getObjectionCount() { return objectionCount; }
public void setObjectionCount(int v) { objectionCount = v; }
public int getResolvedCount() { return resolvedCount; }
public void setResolvedCount(int v) { resolvedCount = v; }
public String getTopReason() { return topReason; }
public void setTopReason(String v) { topReason = v; }
}
}

View File

@@ -0,0 +1,73 @@
package com.ruoyi.system.mapper.bid;
import com.ruoyi.system.domain.bid.ReportDashboardVO;
import com.ruoyi.system.domain.bid.ReportCostVO;
import com.ruoyi.system.domain.bid.ReportSupplierVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 报表统计 Mapper
*/
public interface BizReportMapper {
// ========== 看板 ==========
/** 本月/上月采购总额 */
Map<String, Object> selectPurchaseAmount(@Param("month") String month);
/** 本月/上月 RFQ 数量 */
Map<String, Object> selectRfqCount(@Param("month") String month);
/** 本月/上月 PO 数量 */
Map<String, Object> selectPoCount(@Param("month") String month);
/** 活跃供应商数 */
Map<String, Object> selectActiveSupplierCount();
/** 月度采购趋势近12月 */
List<ReportDashboardVO.MonthTrend> selectMonthlyTrend();
/** RFQ 状态分布 */
List<ReportDashboardVO.StatusDist> selectRfqStatusDist();
/** Top 供应商排名 */
List<ReportDashboardVO.SupplierRank> selectTopSuppliers();
/** 最近动态 */
List<ReportDashboardVO.RecentActivity> selectRecentActivities();
// ========== 成本分析 ==========
/** 预算总额所有已完成RFQ的期望价 */
Map<String, Object> selectTotalExpected();
/** 实际采购总额 */
Map<String, Object> selectTotalActual();
/** 月度成本趋势 */
List<ReportCostVO.CostTrend> selectCostTrend(@Param("startMonth") String startMonth,
@Param("endMonth") String endMonth);
/** 品类采购分布 */
List<ReportCostVO.CategoryDist> selectCategoryDist();
/** RFQ 比价明细 */
List<ReportCostVO.RfqCompareDetail> selectRfqCompareDetails();
// ========== 供应商绩效 ==========
/** 供应商评分排名 */
List<ReportSupplierVO.SupplierScore> selectSupplierScores();
/** 供应商中标率 */
List<ReportSupplierVO.WinRateData> selectWinRate();
/** 供应商雷达图数据 */
List<ReportSupplierVO.RadarData> selectRadarData();
/** 异议统计 */
List<ReportSupplierVO.ObjectionStat> selectObjectionStats();
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.system.service.bid;
import com.ruoyi.system.domain.bid.ReportDashboardVO;
import com.ruoyi.system.domain.bid.ReportCostVO;
import com.ruoyi.system.domain.bid.ReportSupplierVO;
/**
* 报表统计 Service 接口
*/
public interface IBizReportService {
/** 获取采购总览看板数据 */
ReportDashboardVO getDashboard();
/** 获取采购成本分析数据 */
ReportCostVO getCostAnalysis(String startMonth, String endMonth);
/** 获取供应商绩效数据 */
ReportSupplierVO getSupplierPerformance();
}

View File

@@ -0,0 +1,172 @@
package com.ruoyi.system.service.bid.impl;
import com.ruoyi.system.domain.bid.ReportDashboardVO;
import com.ruoyi.system.domain.bid.ReportCostVO;
import com.ruoyi.system.domain.bid.ReportSupplierVO;
import com.ruoyi.system.mapper.bid.BizReportMapper;
import com.ruoyi.system.service.bid.IBizReportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 报表统计 Service 实现
*/
@Service
public class BizReportServiceImpl implements IBizReportService {
@Autowired
private BizReportMapper mapper;
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
@Override
public ReportDashboardVO getDashboard() {
ReportDashboardVO vo = new ReportDashboardVO();
// 当前月 & 上月
String curMonth = LocalDate.now().format(MONTH_FMT);
String lastMonth = LocalDate.now().minusMonths(1).format(MONTH_FMT);
// 1) KPI 卡片
vo.setTotalPurchaseAmount(buildKpi("采购总额",
selectAmount("totalAmount", curMonth),
selectAmount("totalAmount", lastMonth), ""));
vo.setTotalRfqCount(buildKpi("RFQ总数",
selectCount("rfq", curMonth),
selectCount("rfq", lastMonth), ""));
vo.setTotalPoCount(buildKpi("采购单数",
selectCount("po", curMonth),
selectCount("po", lastMonth), ""));
Map<String, Object> act = mapper.selectActiveSupplierCount();
BigDecimal activeVal = toBD(act.get("totalCount"));
vo.setActiveSupplierCount(buildKpi("活跃供应商", activeVal, null, ""));
// 2) 月度趋势
vo.setMonthlyTrend(mapper.selectMonthlyTrend());
// 3) RFQ 状态分布
List<ReportDashboardVO.StatusDist> dist = mapper.selectRfqStatusDist();
// 补充中文标签
for (ReportDashboardVO.StatusDist d : dist) {
if (d.getStatusLabel() == null) {
d.setStatusLabel(d.getStatus());
}
}
vo.setRfqStatusDist(dist);
// 4) Top 供应商
vo.setTopSuppliers(mapper.selectTopSuppliers());
// 5) 最近动态
vo.setRecentActivities(mapper.selectRecentActivities());
return vo;
}
@Override
public ReportCostVO getCostAnalysis(String startMonth, String endMonth) {
ReportCostVO vo = new ReportCostVO();
// 汇总
ReportCostVO.CostSummary summary = new ReportCostVO.CostSummary();
BigDecimal expected = toBD(mapper.selectTotalExpected().get("totalExpected"));
BigDecimal actual = toBD(mapper.selectTotalActual().get("totalActual"));
summary.setTotalExpected(expected);
summary.setTotalActual(actual);
summary.setSavedAmount(expected.subtract(actual).max(BigDecimal.ZERO));
summary.setSavedRate(expected.compareTo(BigDecimal.ZERO) > 0
? summary.getSavedAmount().divide(expected, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100")).setScale(1, RoundingMode.HALF_UP).doubleValue()
: 0);
vo.setSummary(summary);
// 月度趋势
vo.setCostTrend(mapper.selectCostTrend(startMonth, endMonth));
// 品类分布
List<ReportCostVO.CategoryDist> catDist = mapper.selectCategoryDist();
BigDecimal total = catDist.stream()
.map(ReportCostVO.CategoryDist::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
for (ReportCostVO.CategoryDist d : catDist) {
if (total.compareTo(BigDecimal.ZERO) > 0) {
d.setPercent(d.getAmount().divide(total, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100")).setScale(1, RoundingMode.HALF_UP).doubleValue());
}
}
vo.setCategoryDist(catDist);
// RFQ 比价明细
vo.setRfqDetails(mapper.selectRfqCompareDetails());
return vo;
}
@Override
public ReportSupplierVO getSupplierPerformance() {
ReportSupplierVO vo = new ReportSupplierVO();
vo.setRankings(mapper.selectSupplierScores());
vo.setWinRateData(mapper.selectWinRate());
vo.setRadarData(mapper.selectRadarData());
vo.setObjectionStats(mapper.selectObjectionStats());
return vo;
}
// ===== 私有工具方法 =====
private ReportDashboardVO.KpiCard buildKpi(String label, BigDecimal curVal, BigDecimal lastVal, String unit) {
ReportDashboardVO.KpiCard card = new ReportDashboardVO.KpiCard();
card.setLabel(label);
card.setValue(curVal);
card.setUnit(unit);
if (lastVal != null && lastVal.compareTo(BigDecimal.ZERO) > 0) {
double rate = curVal.subtract(lastVal)
.divide(lastVal, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal("100"))
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
card.setChangeRate(Math.abs(rate));
card.setTrend(rate >= 0 ? "up" : "down");
} else {
card.setChangeRate(0);
card.setTrend("up");
}
return card;
}
private BigDecimal selectAmount(String column, String month) {
Map<String, Object> map = mapper.selectPurchaseAmount(month);
return toBD(map.get(column));
}
private BigDecimal selectCount(String type, String month) {
Map<String, Object> map;
switch (type) {
case "rfq": map = mapper.selectRfqCount(month); break;
case "po": map = mapper.selectPoCount(month); break;
default: return BigDecimal.ZERO;
}
return toBD(map.get("totalCount"));
}
private BigDecimal toBD(Object v) {
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal) return (BigDecimal) v;
if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue());
return BigDecimal.ZERO;
}
}

View File

@@ -0,0 +1,325 @@
<?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.BizReportMapper">
<!-- ================================================================ -->
<!-- 采 购 总 览 看 板 -->
<!-- ================================================================ -->
<!-- 指定月份采购总额 -->
<select id="selectPurchaseAmount" resultType="java.util.HashMap">
SELECT COALESCE(SUM(total_amount), 0) AS totalAmount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
<if test="month != null and month != ''">
AND DATE_FORMAT(create_time, '%Y-%m') = #{month}
</if>
</select>
<!-- 指定月份 RFQ 数量 -->
<select id="selectRfqCount" resultType="java.util.HashMap">
SELECT COUNT(*) AS totalCount
FROM biz_rfq
WHERE 1=1
<if test="month != null and month != ''">
AND DATE_FORMAT(create_time, '%Y-%m') = #{month}
</if>
</select>
<!-- 指定月份 PO 数量 -->
<select id="selectPoCount" resultType="java.util.HashMap">
SELECT COUNT(*) AS totalCount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
<if test="month != null and month != ''">
AND DATE_FORMAT(create_time, '%Y-%m') = #{month}
</if>
</select>
<!-- 活跃供应商数(有过报价或采购的) -->
<select id="selectActiveSupplierCount" resultType="java.util.HashMap">
SELECT COUNT(DISTINCT supplier_id) AS totalCount
FROM (
SELECT supplier_id FROM biz_quotation WHERE status IN ('submitted', 'accepted')
UNION
SELECT supplier_id FROM biz_purchase_order
) t
</select>
<!-- 月度采购趋势 -->
<select id="selectMonthlyTrend" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$MonthTrend">
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month,
COALESCE(SUM(total_amount), 0) AS amount,
COUNT(*) AS count
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
AND create_time >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(create_time, '%Y-%m')
ORDER BY month ASC
</select>
<!-- RFQ 状态分布 -->
<select id="selectRfqStatusDist" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$StatusDist">
SELECT status,
CASE status
WHEN 'draft' THEN '草稿'
WHEN 'published' THEN '已发布'
WHEN 'completed' THEN '已完成'
WHEN 'cancelled' THEN '已取消'
ELSE status
END AS statusLabel,
COUNT(*) AS count,
ROUND(COUNT(*) / (SELECT COUNT(*) FROM biz_rfq) * 100, 1) AS percent
FROM biz_rfq
GROUP BY status
</select>
<!-- Top 供应商排名 -->
<select id="selectTopSuppliers" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$SupplierRank">
SELECT p.supplier_id AS supplierId,
s.supplier_name AS supplierName,
COALESCE(SUM(p.total_amount), 0) AS totalAmount,
COUNT(*) AS poCount,
COALESCE(AVG(e.total_score), 0) AS avgScore
FROM biz_purchase_order p
LEFT JOIN biz_supplier s ON p.supplier_id = s.supplier_id
LEFT JOIN biz_supplier_evaluation e ON p.po_id = e.po_id
WHERE p.status IN ('confirmed', 'closed', 'delivered')
GROUP BY p.supplier_id, s.supplier_name
ORDER BY totalAmount DESC
LIMIT 5
</select>
<!-- 最近动态 -->
<select id="selectRecentActivities" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$RecentActivity">
SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:%i') AS time,
'PO' AS type,
CONCAT('创建采购单 ', po_no, '(¥', total_amount, '') AS `desc`,
po_id AS linkId
FROM biz_purchase_order
UNION ALL
SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:%i') AS time,
'QUOTE' AS type,
CONCAT('供应商报价 ', quote_no, '(¥', total_amount, '') AS `desc`,
quotation_id AS linkId
FROM biz_quotation
WHERE status IN ('submitted', 'accepted')
UNION ALL
SELECT DATE_FORMAT(eval_time, '%Y-%m-%d %H:%i') AS time,
'EVAL' AS type,
CONCAT('评价供应商:', s.supplier_name, '', e.total_score, '分)') AS `desc`,
e.eval_id AS linkId
FROM biz_supplier_evaluation e
LEFT JOIN biz_supplier s ON e.supplier_id = s.supplier_id
UNION ALL
SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:%i') AS time,
'OBJECTION' AS type,
CONCAT('订单异议:', LEFT(o.reason, 30)) AS `desc`,
o.objection_id AS linkId
FROM biz_order_objection o
ORDER BY time DESC
LIMIT 10
</select>
<!-- ================================================================ -->
<!-- 采 购 成 本 分 析 -->
<!-- ================================================================ -->
<!-- 预算总额 -->
<select id="selectTotalExpected" resultType="java.util.HashMap">
SELECT COALESCE(SUM(ri.quantity * ri.expected_price), 0) AS totalExpected
FROM biz_rfq r
JOIN biz_rfq_item ri ON r.rfq_id = ri.rfq_id
WHERE r.status = 'completed'
</select>
<!-- 实际采购总额 -->
<select id="selectTotalActual" resultType="java.util.HashMap">
SELECT COALESCE(SUM(total_amount), 0) AS totalActual
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
</select>
<!-- 月度成本趋势 -->
<select id="selectCostTrend" resultType="com.ruoyi.system.domain.bid.ReportCostVO$CostTrend">
SELECT
t.month,
COALESCE(e.expectedAmount, 0) AS expectedAmount,
COALESCE(a.actualAmount, 0) AS actualAmount,
COALESCE(e.expectedAmount, 0) - COALESCE(a.actualAmount, 0) AS savedAmount
FROM (
SELECT DISTINCT DATE_FORMAT(create_time, '%Y-%m') AS month
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
UNION
SELECT DISTINCT DATE_FORMAT(create_time, '%Y-%m') FROM biz_rfq
) t
LEFT JOIN (
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month,
SUM(ri.quantity * ri.expected_price) AS expectedAmount
FROM biz_rfq r
JOIN biz_rfq_item ri ON r.rfq_id = ri.rfq_id
WHERE r.status = 'completed'
GROUP BY DATE_FORMAT(create_time, '%Y-%m')
) e ON t.month = e.month
LEFT JOIN (
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month,
SUM(total_amount) AS actualAmount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
GROUP BY DATE_FORMAT(create_time, '%Y-%m')
) a ON t.month = a.month
<where>
<if test="startMonth != null and startMonth != ''">
AND t.month >= #{startMonth}
</if>
<if test="endMonth != null and endMonth != ''">
AND t.month &lt;= #{endMonth}
</if>
</where>
ORDER BY t.month ASC
</select>
<!-- 品类采购分布 -->
<select id="selectCategoryDist" resultType="com.ruoyi.system.domain.bid.ReportCostVO$CategoryDist">
SELECT COALESCE(c.category_name, '未分类') AS categoryName,
COALESCE(SUM(pi.total_price), 0) AS amount,
COUNT(DISTINCT pi.material_id) AS materialCount
FROM biz_purchase_order_item pi
JOIN biz_purchase_order p ON pi.po_id = p.po_id
LEFT JOIN biz_material m ON pi.material_id = m.material_id
LEFT JOIN biz_material_category c ON m.category_id = c.category_id
WHERE p.status IN ('confirmed', 'closed', 'delivered')
GROUP BY c.category_name
ORDER BY amount DESC
</select>
<!-- RFQ 比价明细 -->
<select id="selectRfqCompareDetails" resultType="com.ruoyi.system.domain.bid.ReportCostVO$RfqCompareDetail">
SELECT
r.rfq_id AS rfqId,
r.rfq_no AS rfqNo,
r.rfq_title AS rfqTitle,
ri.expectedTotal,
COALESCE(lowest.lowestQuote, 0) AS lowestQuote,
COALESCE(accepted.acceptedQuote, 0) AS acceptedQuote,
ri.expectedTotal - COALESCE(accepted.acceptedQuote, 0) AS savedAmount,
COALESCE(sup.supplierCount, 0) AS supplierCount
FROM biz_rfq r
JOIN (
SELECT rfq_id,
COALESCE(SUM(quantity * expected_price), 0) AS expectedTotal
FROM biz_rfq_item
GROUP BY rfq_id
) ri ON r.rfq_id = ri.rfq_id
LEFT JOIN (
SELECT q.rfq_id,
MIN(q.total_amount) AS lowestQuote
FROM biz_quotation q
WHERE q.status IN ('submitted', 'accepted', 'rejected')
AND q.total_amount > 0
GROUP BY q.rfq_id
) lowest ON r.rfq_id = lowest.rfq_id
LEFT JOIN (
SELECT q.rfq_id,
MAX(q.total_amount) AS acceptedQuote
FROM biz_quotation q
WHERE q.status = 'accepted'
GROUP BY q.rfq_id
) accepted ON r.rfq_id = accepted.rfq_id
LEFT JOIN (
SELECT q.rfq_id,
COUNT(DISTINCT q.supplier_id) AS supplierCount
FROM biz_quotation q
WHERE q.status IN ('submitted', 'accepted', 'rejected')
AND q.total_amount > 0
GROUP BY q.rfq_id
) sup ON r.rfq_id = sup.rfq_id
WHERE r.status = 'completed'
ORDER BY r.create_time DESC
</select>
<!-- ================================================================ -->
<!-- 供 应 商 绩 效 -->
<!-- ================================================================ -->
<!-- 供应商评分排名 -->
<select id="selectSupplierScores" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$SupplierScore">
SELECT
s.supplier_id AS supplierId,
s.supplier_name AS supplierName,
COALESCE(e.evalCount, 0) AS evalCount,
COALESCE(e.qualityAvg, 0) AS qualityAvg,
COALESCE(e.deliveryAvg, 0) AS deliveryAvg,
COALESCE(e.serviceAvg, 0) AS serviceAvg,
COALESCE(e.priceAvg, 0) AS priceAvg,
COALESCE(e.totalAvg, 0) AS totalAvg,
COALESCE(po.poCount, 0) AS poCount,
COALESCE(po.poAmount, 0) AS poAmount
FROM biz_supplier s
LEFT JOIN (
SELECT supplier_id,
COUNT(*) AS evalCount,
AVG(quality_score) AS qualityAvg,
AVG(delivery_score) AS deliveryAvg,
AVG(service_score) AS serviceAvg,
AVG(price_score) AS priceAvg,
AVG(total_score) AS totalAvg
FROM biz_supplier_evaluation
GROUP BY supplier_id
) e ON s.supplier_id = e.supplier_id
LEFT JOIN (
SELECT supplier_id,
COUNT(*) AS poCount,
SUM(total_amount) AS poAmount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
GROUP BY supplier_id
) po ON s.supplier_id = po.supplier_id
ORDER BY totalAvg DESC
</select>
<!-- 中标率 -->
<select id="selectWinRate" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$WinRateData">
SELECT
q.supplier_id AS supplierId,
s.supplier_name AS supplierName,
COUNT(*) AS totalQuotes,
SUM(CASE WHEN q.status = 'accepted' THEN 1 ELSE 0 END) AS winCount,
ROUND(SUM(CASE WHEN q.status = 'accepted' THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) AS winRate
FROM biz_quotation q
JOIN biz_supplier s ON q.supplier_id = s.supplier_id
WHERE q.status IN ('accepted', 'rejected')
GROUP BY q.supplier_id, s.supplier_name
ORDER BY winRate DESC
</select>
<!-- 雷达图数据 -->
<select id="selectRadarData" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$RadarData">
SELECT
s.supplier_name AS supplierName,
COALESCE(AVG(e.quality_score), 0) AS quality,
COALESCE(AVG(e.delivery_score), 0) AS delivery,
COALESCE(AVG(e.service_score), 0) AS service,
COALESCE(AVG(e.price_score), 0) AS price
FROM biz_supplier s
LEFT JOIN biz_supplier_evaluation e ON s.supplier_id = e.supplier_id
GROUP BY s.supplier_id, s.supplier_name
ORDER BY s.supplier_id
</select>
<!-- 异议统计 -->
<select id="selectObjectionStats" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$ObjectionStat">
SELECT
o.supplier_id AS supplierId,
s.supplier_name AS supplierName,
COUNT(*) AS objectionCount,
SUM(CASE WHEN o.status = 'resolved' THEN 1 ELSE 0 END) AS resolvedCount,
SUBSTRING_INDEX(GROUP_CONCAT(o.reason ORDER BY o.create_time DESC SEPARATOR ''), '', 1) AS topReason
FROM biz_order_objection o
JOIN biz_supplier s ON o.supplier_id = s.supplier_id
GROUP BY o.supplier_id, s.supplier_name
ORDER BY objectionCount DESC
</select>
</mapper>