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,987 @@
# 智慧报价系统 — 统计报表模块开发方案
> 基于 RuoYi-Vue v3.9.2 (Spring Boot 4.0.3 + JDK 17 + Vue 2 + Element UI + ECharts 5.4.0)
> 对接现有"智慧报价"模块 12 张业务表 & 真实生产数据
---
## 目录
- [一、设计理念](#一设计理念)
- [二、报表总览](#二报表总览)
- [三、页面架构与路由设计](#三页面架构与路由设计)
- [四、后端详细设计](#四后端详细设计)
- [五、前端详细设计](#五前端详细设计)
- [六、数据库查询方案SQL 核心)](#六数据库查询方案sql核心)
- [七、权限与菜单设计](#七权限与菜单设计)
- [八、实施步骤与里程碑](#八实施步骤与里程碑)
- [九、文件清单与代码量预估](#九文件清单与代码量预估)
---
## 一、设计理念
### 1.1 目标
老板想看的不是原始数据的堆砌,而是**一眼能抓住的数据洞察**。因此本报表模块的核心原则:
| 原则 | 说明 |
|------|------|
| **KPI 优先** | 每个页面顶部展示关键指标卡片(总计/环比/趋势),让老板一眼看到业务状况 |
| **图文并茂** | ECharts 图表为主,表格为辅,避免纯表格枯燥 |
| **层层下钻** | 从汇总趋势 → 点击进入明细 → 再进详情,支持交互式探索 |
| **聚焦决策** | 报表要能回答老板的问题:花了多少钱?省了多少钱?哪家供应商最好? |
### 1.2 技术选型理由
| 技术 | 为什么选 |
|------|---------|
| **ECharts 5.4.0** | 项目已有依赖(`package.json``echarts: 5.4.0`),无需额外安装 |
| **Vue 2 + Element UI** | 项目现有技术栈,风格统一 |
| **MyBatis 手写 SQL** | 报表查询需要多表聚合,手写 SQL 最灵活 |
| **RuoYi BaseController** | 延续 `startPage()` + `getDataTable()` 分页模式,与现有代码一致 |
---
## 二、报表总览
### 2.1 报表菜单结构
在**智慧报价**菜单下新增一个分组 **统计分析**,包含 4 个子页面:
```
智慧报价 (menu_id=2000)
├── 物料管理 (2001)
├── 供应商管理 (2002)
├── 报价请求 (2003)
├── 供应商报价 (2004)
├── 智慧比价 (2005)
├── 采购单 (2006)
├── 供应商评价 (2007)
├── 订单异议 (2008)
├── 交易记录 (2009)
├── 租户管理 (2010)
├── ═══════════════════════════════
├── 📊 统计分析 (2011) ← 新增目录菜单(父节点)
│ ├── 📈 采购总览看板 (2012) ← 仪表盘首页
│ ├── 💰 采购成本分析 (2013) ← 成本趋势与对比
│ └── 🏆 供应商绩效 (2014) ← 供应商评分与排名
```
### 2.2 四个报表页面说明
| 页面 | 路由 path | 定位 | 老板能回答什么问题 |
|------|-----------|------|-------------------|
| **采购总览看板** | `/bid/report/dashboard` | 仪表盘首页,一屏总览 | 本月花了多少钱?采购趋势怎么样?报价情况如何? |
| **采购成本分析** | `/bid/report/cost` | 成本深度分析 | 比预算省了多少?各品类花了多少?同物料价格对比? |
| **供应商绩效** | `/bid/report/supplier` | 供应商评分与排名 | 哪家供应商最好?哪家经常出问题?交期/质量/服务排名? |
---
## 三、页面架构与路由设计
### 3.1 前端路由注册
`ruoyi-ui/src/router/index.js``dynamicRoutes` 中添加:
```js
// ── 统计分析 路由 ──
{
path: '/bid/report/dashboard',
component: Layout,
hidden: true,
permissions: ['bid:report:dashboard'],
children: [{
path: '',
component: () => import('@/views/bid/report/dashboard'),
name: 'ReportDashboard',
meta: { title: '采购总览看板', activeMenu: '/bid/report' }
}]
},
{
path: '/bid/report/cost',
component: Layout,
hidden: true,
permissions: ['bid:report:cost'],
children: [{
path: '',
component: () => import('@/views/bid/report/cost'),
name: 'ReportCost',
meta: { title: '采购成本分析', activeMenu: '/bid/report' }
}]
},
{
path: '/bid/report/supplier',
component: Layout,
hidden: true,
permissions: ['bid:report:supplier'],
children: [{
path: '',
component: () => import('@/views/bid/report/supplier'),
name: 'ReportSupplier',
meta: { title: '供应商绩效', activeMenu: '/bid/report' }
}]
}
```
### 3.2 菜单 SQL
`bid_tables.sql` 或系统菜单表中执行以下 SQL先查询最大 menu_id 再插入):
```sql
-- 父菜单:统计分析 (2011)
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES(2011, '统计分析', 2000, 11, 'report', NULL, 1, 0, 'M', '0', '0', 'bid:report:list', 'chart', 'admin', NOW());
-- 子菜单:采购总览看板 (2012)
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES(2012, '采购总览看板', 2011, 1, 'dashboard', 'bid/report/dashboard', 1, 0, 'C', '0', '0', 'bid:report:dashboard', 'dashboard', 'admin', NOW());
-- 子菜单:采购成本分析 (2013)
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES(2013, '采购成本分析', 2011, 2, 'cost', 'bid/report/cost', 1, 0, 'C', '0', '0', 'bid:report:cost', 'money', 'admin', NOW());
-- 子菜单:供应商绩效 (2014)
INSERT IGNORE INTO sys_menu(menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES(2014, '供应商绩效', 2011, 3, 'supplier', 'bid/report/supplier', 1, 0, 'C', '0', '0', 'bid:report:supplier', 'star', 'admin', NOW());
```
---
## 四、后端详细设计
### 4.1 包结构
```
ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/
└── BizReportController.java ← 报表统计 Controller
ruoyi-system/src/main/java/com/ruoyi/system/
├── domain/bid/
│ ├── ReportDashboardVO.java ← 看板数据 VO
│ ├── ReportCostVO.java ← 成本分析 VO
│ └── ReportSupplierVO.java ← 供应商绩效 VO
├── mapper/bid/
│ └── BizReportMapper.java ← 报表 Mapper 接口
├── service/bid/
│ ├── IBizReportService.java ← 报表 Service 接口
│ └── impl/
│ └── BizReportServiceImpl.java ← 报表 Service 实现
ruoyi-system/src/main/resources/mapper/bid/
└── BizReportMapper.xml ← 报表 SQL 映射
```
### 4.2 Domain / VO 设计
#### ReportDashboardVO.java — 看板数据
```java
public class ReportDashboardVO {
// ====== 顶部 KPI 卡片 ======
private KpiCard totalPurchaseAmount; // 采购总额(含环比)
private KpiCard totalRfqCount; // RFQ总数
private KpiCard totalPoCount; // 采购单数
private KpiCard activeSupplierCount; // 活跃供应商数
// ====== 图表数据 ======
private List<MonthTrend> monthlyTrend; // 月度采购趋势
private List<StatusDist> rfqStatusDist; // RFQ状态分布
private List<SupplierRank> topSuppliers; // Top供应商排名
private List<RecentActivity> recentActivities; // 最近动态
// ====== 内部类 ======
@Data
public static class KpiCard {
private String label; // 指标名
private BigDecimal value; // 当前值
private double changeRate; // 环比(%)
private String unit; // 单位
private String trend; // up/down
}
@Data
public static class MonthTrend {
private String month; // "2026-01"
private BigDecimal amount; // 金额
private int count; // 单数
}
@Data
public static class StatusDist {
private String status;
private String statusLabel;
private int count;
private double percent;
}
@Data
public static class SupplierRank {
private Long supplierId;
private String supplierName;
private BigDecimal totalAmount; // 采购金额
private int poCount; // 采购次数
private double avgScore; // 评价均分
}
@Data
public static class RecentActivity {
private String time;
private String type; // PO / QUOTE / EVAL / OBJECTION
private String desc;
private String linkId;
}
}
```
#### ReportCostVO.java — 成本分析
```java
public class ReportCostVO {
// ====== 顶部汇总 ======
private CostSummary summary;
@Data
public static class CostSummary {
private BigDecimal totalExpected; // 预算总额RFQ期望价
private BigDecimal totalActual; // 实际采购总额
private BigDecimal savedAmount; // 节省金额
private double savedRate; // 节省比例
}
// ====== 月度成本趋势 ======
private List<CostTrend> costTrend;
@Data
public static class CostTrend {
private String month;
private BigDecimal expectedAmount;
private BigDecimal actualAmount;
private BigDecimal savedAmount;
}
// ====== 品类采购分布 ======
private List<CategoryDist> categoryDist;
@Data
public static class CategoryDist {
private String categoryName;
private BigDecimal amount;
private int materialCount;
private double percent;
}
// ====== 单次RFQ比价详情 ======
private List<RfqCompareDetail> rfqDetails;
@Data
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;
}
}
```
#### ReportSupplierVO.java — 供应商绩效
```java
public class ReportSupplierVO {
// ====== 评分排名 ======
private List<SupplierScore> rankings;
@Data
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; // 采购金额
private double winRate; // 中标率 %
}
// ====== 供应商评价雷达图数据 ======
private List<RadarData> radarData;
@Data
public static class RadarData {
private String supplierName;
private double quality;
private double delivery;
private double service;
private double price;
}
// ====== 月度评分趋势 ======
private List<ScoreTrend> scoreTrends;
@Data
public static class ScoreTrend {
private String month;
private Long supplierId;
private String supplierName;
private double score;
}
// ====== 异议统计 ======
private List<ObjectionStat> objectionStats;
@Data
public static class ObjectionStat {
private Long supplierId;
private String supplierName;
private int objectionCount;
private int resolvedCount;
private String topReason;
}
}
```
### 4.3 Controller 设计
```java
@RestController
@RequestMapping("/bid/report")
public class BizReportController extends BaseController {
@Autowired private IBizReportService reportService;
/** 采购总览看板数据 */
@PreAuthorize("@ss.hasPermi('bid:report:dashboard')")
@GetMapping("/dashboard")
public AjaxResult dashboard() {
return success(reportService.getDashboard());
}
/** 采购成本分析数据 */
@PreAuthorize("@ss.hasPermi('bid:report:cost')")
@GetMapping("/cost")
public AjaxResult costAnalysis(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM") String startMonth,
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM") String endMonth) {
return success(reportService.getCostAnalysis(startMonth, endMonth));
}
/** 供应商绩效数据 */
@PreAuthorize("@ss.hasPermi('bid:report:supplier')")
@GetMapping("/supplier")
public AjaxResult supplierPerformance() {
return success(reportService.getSupplierPerformance());
}
/** 导出报表 Excel */
@PreAuthorize("@ss.hasPermi('bid:report:list')")
@Log(title = "报表导出", businessType = BusinessType.EXPORT)
@PostMapping("/export/{type}")
public void export(HttpServletResponse response, @PathVariable String type) {
// 使用 RuoYi ExcelUtil 导出type=dashboard/cost/supplier
}
}
```
### 4.4 Service 实现概要
```java
@Service
public class BizReportServiceImpl implements IBizReportService {
@Autowired private BizReportMapper mapper;
@Override
public ReportDashboardVO getDashboard() {
ReportDashboardVO vo = new ReportDashboardVO();
// 1. KPI 卡片:本月数据 vs 上月
vo.setTotalPurchaseAmount(calcKpi("PO", "totalAmount"));
vo.setTotalRfqCount(calcKpi("RFQ", "count"));
vo.setTotalPoCount(calcKpi("PO", "count"));
vo.setActiveSupplierCount(calcKpi("SUPPLIER", "activeCount"));
// 2. 月度趋势近12个月
vo.setMonthlyTrend(mapper.selectMonthlyTrend());
// 3. RFQ 状态分布
vo.setRfqStatusDist(mapper.selectRfqStatusDist());
// 4. Top 5 供应商排名
vo.setTopSuppliers(mapper.selectTopSuppliers());
// 5. 最近10条动态
vo.setRecentActivities(mapper.selectRecentActivities());
return vo;
}
// ... 其他方法
}
```
### 4.5 Mapper XML 核心 SQL
#### BizReportMapper.xml
```xml
<?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">
<!-- ═══════════════════ 看板 ═══════════════════ -->
<!-- 月度采购趋势近12个月按月汇总 -->
<select id="selectMonthlyTrend" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$MonthTrend">
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month,
SUM(total_amount) 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, COUNT(*) AS count
FROM biz_rfq
GROUP BY status
</select>
<!-- Top 5 供应商排名(按采购金额) -->
<select id="selectTopSuppliers" resultType="com.ruoyi.system.domain.bid.ReportDashboardVO$SupplierRank">
SELECT p.supplier_id, s.supplier_name,
SUM(p.total_amount) 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 'PO' AS type, po_no AS typeNo, total_amount AS amount,
CONCAT('创建采购单 ', po_no) AS description,
create_time
FROM biz_purchase_order
UNION ALL
SELECT 'QUOTE', quote_no, total_amount,
CONCAT('供应商报价 ', quote_no),
create_time
FROM biz_quotation WHERE status IN ('submitted', 'accepted')
UNION ALL
SELECT 'EVAL', CONCAT('EVAL-', eval_id), NULL,
CONCAT('评价供应商:', comment),
eval_time
FROM biz_supplier_evaluation
ORDER BY create_time DESC
LIMIT 10
</select>
<!-- ═══════════════════ 成本分析 ═══════════════════ -->
<!-- 月度预算 vs 实际成本对比 -->
<select id="selectCostTrend" resultType="com.ruoyi.system.domain.bid.ReportCostVO$CostTrend">
SELECT t.month,
COALESCE(SUM(e.total_expected), 0) AS expectedAmount,
COALESCE(SUM(a.total_actual), 0) AS actualAmount
FROM (
SELECT DATE_FORMAT(r.create_time, '%Y-%m') AS month,
SUM(ri.quantity * ri.expected_price) AS total_expected,
0 AS total_actual
FROM biz_rfq r
JOIN biz_rfq_item ri ON r.rfq_id = ri.rfq_id
WHERE r.status = 'completed'
GROUP BY DATE_FORMAT(r.create_time, '%Y-%m')
) e
LEFT JOIN (
SELECT DATE_FORMAT(p.create_time, '%Y-%m') AS month,
SUM(p.total_amount) AS total_actual
FROM biz_purchase_order p
WHERE p.status IN ('confirmed', 'closed', 'delivered')
GROUP BY DATE_FORMAT(p.create_time, '%Y-%m')
) a ON e.month = a.month
GROUP BY t.month
ORDER BY month
</select>
<!-- 品类采购分布 -->
<select id="selectCategoryDist" resultType="com.ruoyi.system.domain.bid.ReportCostVO$CategoryDist">
SELECT COALESCE(c.category_name, '未分类') AS categoryName,
SUM(pi.total_price) 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>
<!-- ═══════════════════ 供应商绩效 ═══════════════════ -->
<!-- 供应商评分排名 -->
<select id="selectSupplierScores" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$SupplierScore">
SELECT s.supplier_id, s.supplier_name,
COUNT(e.eval_id) AS evalCount,
COALESCE(AVG(e.quality_score), 0) AS qualityAvg,
COALESCE(AVG(e.delivery_score), 0) AS deliveryAvg,
COALESCE(AVG(e.service_score), 0) AS serviceAvg,
COALESCE(AVG(e.price_score), 0) AS priceAvg,
COALESCE(AVG(e.total_score), 0) AS totalAvg,
COALESCE(po.poCount, 0) AS poCount,
COALESCE(po.poAmount, 0) AS poAmount
FROM biz_supplier s
LEFT JOIN biz_supplier_evaluation 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
GROUP BY s.supplier_id, s.supplier_name
ORDER BY totalAvg DESC
</select>
<!-- 供应商中标率 -->
<select id="selectWinRate" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$SupplierScore">
SELECT q.supplier_id, s.supplier_name,
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', 'submitted')
GROUP BY q.supplier_id, s.supplier_name
ORDER BY winRate DESC
</select>
<!-- 异议统计 -->
<select id="selectObjectionStats" resultType="com.ruoyi.system.domain.bid.ReportSupplierVO$ObjectionStat">
SELECT o.supplier_id, s.supplier_name,
COUNT(*) AS objectionCount,
SUM(CASE WHEN o.status = 'resolved' THEN 1 ELSE 0 END) AS resolvedCount
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>
```
---
## 五、前端详细设计
### 5.1 文件结构
```
ruoyi-ui/src/views/bid/report/
├── dashboard.vue ← 采购总览看板
├── cost.vue ← 采购成本分析
├── supplier.vue ← 供应商绩效
└── components/ ← 可复用组件
├── KpiCard.vue ← 指标卡片
└── TrendChart.vue ← 趋势图表封装
ruoyi-ui/src/api/bid/
└── report.js ← API 封装
```
### 5.2 API 封装report.js
```js
import request from '@/utils/request'
const baseUrl = '/bid/report'
export const getDashboard = () => request({ url: baseUrl + '/dashboard', method: 'get' })
export const getCostAnalysis = (params) => request({ url: baseUrl + '/cost', method: 'get', params })
export const getSupplierPerformance = () => request({ url: baseUrl + '/supplier', method: 'get' })
export const exportReport = (type, data) => request({
url: baseUrl + '/export/' + type,
method: 'post',
data,
responseType: 'blob'
})
```
### 5.3 页面一采购总览看板dashboard.vue
布局方案:
```
┌─────────────────────────────────────────────────────────┐
│ 📊 采购总览看板 │
├────────────┬────────────┬────────────┬──────────────────┤
│ 采购总额 │ RFQ总数 │ 采购单数 │ 活跃供应商 │
│ ¥207,820 │ 7 单 │ 3 单 │ 5 家 │
│ ↑ 12.5% │ ↑ 40% │ 持平 │ ↑ 25% │
├────────────┴────────────┴────────────┴──────────────────┤
│ │
│ ┌───────────────────┐ ┌─────────────────────────┐ │
│ │ 月度采购趋势 │ │ RFQ 状态分布 │ │
│ │ (折线图+柱状图) │ │ (饼图) │ │
│ │ │ │ │ │
│ └───────────────────┘ └─────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌─────────────────────────┐ │
│ │ 供应商排名 Top5 │ │ 最新动态 │ │
│ │ (横向柱状图) │ │ (时间线列表) │ │
│ │ │ │ │ │
│ └───────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
**关键代码片段:**
#### ECharts 图表配置示例(月度趋势)
```javascript
// 在 dashboard.vue 的 methods 中
initTrendChart(data) {
const chart = echarts.init(document.getElementById('trendChart'))
const months = data.map(d => d.month)
const amounts = data.map(d => d.amount)
const counts = data.map(d => d.count)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['采购金额', '采购单数'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: months },
yAxis: [
{ type: 'value', name: '金额(元)' },
{ type: 'value', name: '单数' }
],
series: [
{
name: '采购金额',
type: 'bar',
data: amounts,
itemStyle: { color: '#409EFF' },
label: { show: true, formatter: '¥{c}', position: 'top' }
},
{
name: '采购单数',
type: 'line',
yAxisIndex: 1,
data: counts,
itemStyle: { color: '#67C23A' }
}
]
})
}
```
#### KPI 卡片组件KpiCard.vue
```vue
<template>
<el-card shadow="hover" class="kpi-card" :style="cardStyle">
<div class="kpi-label">{{ label }}</div>
<div class="kpi-value">
<span class="kpi-number">{{ displayValue }}</span>
<span class="kpi-unit">{{ unit }}</span>
</div>
<div class="kpi-trend" :class="trendClass">
<i :class="trendIcon"></i>
{{ changeRateText }}
<span class="trend-label">环比上月</span>
</div>
</el-card>
</template>
<script>
export default {
props: {
label: String, value: [Number, String], unit: { type: String, default: '' },
changeRate: { type: Number, default: 0 }
},
computed: {
displayValue() {
if (typeof this.value === 'number' && this.value >= 10000 && this.unit === '') {
return '¥' + this.value.toLocaleString()
}
return this.value
},
trendClass() { return this.changeRate >= 0 ? 'trend-up' : 'trend-down' },
trendIcon() { return this.changeRate >= 0 ? 'el-icon-top' : 'el-icon-bottom' },
changeRateText() { return Math.abs(this.changeRate).toFixed(1) + '%' },
cardStyle() {
const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C']
const idx = ['采购总额', 'RFQ总数', '采购单数'].indexOf(this.label)
return { '--kpi-accent': colors[Math.max(idx, 0) % colors.length] }
}
}
}
</script>
<style scoped>
.kpi-card { border-left: 4px solid var(--kpi-accent, #409EFF); }
.kpi-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
.kpi-value { margin-bottom: 8px; }
.kpi-number { font-size: 28px; font-weight: 700; color: #303133; }
.kpi-unit { font-size: 14px; color: #909399; margin-left: 4px; }
.kpi-trend { font-size: 13px; }
.trend-up { color: #67C23A; }
.trend-down { color: #F56C6C; }
.trend-label { color: #C0C4CC; margin-left: 4px; font-size: 12px; }
</style>
```
### 5.4 页面二采购成本分析cost.vue
布局方案:
```
┌─────────────────────────────────────────────────────────┐
│ 💰 采购成本分析 │
├─────────────────────────────────────────────────────────┤
│ 预算总额: ¥XXX 实际采购: ¥XXX 节省: ¥XXX (XX%) │
├──────────────────────┬──────────────────────────────────┤
│ │ │
│ 月度预算 vs 实际 │ 品类采购分布 │
│ (双柱状对比图) │ (饼图/环形图) │
│ │ │
├──────────────────────┴──────────────────────────────────┤
│ │
│ RFQ 比价明细(表格) │
│ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
│ │询价单 │预算价 │最低价 │采纳价 │节省额 │比价数 │操作 │ │
│ ├──────┼──────┼──────┼──────┼──────┼──────┼──────┤ │
│ │ ... │ ... │ ... │ ... │ ... │ ... │ 详情 │ │
│ └──────┴──────┴──────┴──────┴──────┴──────┴──────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
**核心逻辑:**
1. 查询所有 completed 的 RFQ关联 RFQ Item 中的 `expected_price` 作为预算价
2. 查询对应被采纳的报价(`quotation.status = 'accepted'`)作为实际采购价
3. `节省额 = 预算总额 - 实际采购总额`
4. 表格中每行是一个 RFQ点击"详情"跳转到比价详情页
### 5.5 页面三供应商绩效supplier.vue
布局方案:
```
┌─────────────────────────────────────────────────────────┐
│ 🏆 供应商绩效 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 供应商综合评分排名(表格) │ │
│ │ 排名 │ 供应商 │ 质量 │ 交期│ 服务│ 价格│ 综合│ 操作│ │
│ │ 1 │ 华顺达 │ 5.0 │ 4.0│ 5.0│ 4.0│ 4.5│ 详情│ │
│ │ 2 │ 博远 │ 5.0 │ 3.0│ 5.0│ 5.0│ 4.5│ 详情│ │
│ │ ... │ ... │ ... │ ...│ ...│ ...│ ...│ ... │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌────────────────────────┐ │
│ │ 供应商评价雷达图 │ │ 中标率分析 │ │
│ │ (可切换供应商) │ │ (柱状图) │ │
│ │ │ │ │ │
│ └─────────────────────┘ └────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 订单异议统计(表格) │ │
│ │ 供应商 │ 异议数 │ 已解决 │ 解决率 │ 操作 │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
**核心交互:**
1. 评分排名表格 - 可点击表头排序
2. 雷达图 - 点击表格行可切换显示的供应商
3. 中标率 - 柱状图展示每个供应商的报价次数 vs 中标次数
4. 异议统计 - 展示各供应商的订单问题情况
---
## 六、数据库查询方案SQL 核心)
### 6.1 关键业务指标 SQL
#### 月度采购金额与环比
```sql
-- 当月采购总额
SELECT COALESCE(SUM(total_amount), 0) AS currentMonthAmount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
AND DATE_FORMAT(create_time, '%Y-%m') = DATE_FORMAT(NOW(), '%Y-%m');
-- 上月采购总额(环比基准)
SELECT COALESCE(SUM(total_amount), 0) AS lastMonthAmount
FROM biz_purchase_order
WHERE status IN ('confirmed', 'closed', 'delivered')
AND DATE_FORMAT(create_time, '%Y-%m') = DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m');
-- 环比 = (当月 - 上月) / 上月 * 100
```
#### 预算节省分析
```sql
-- 每个 RFQ 的预算 vs 实际采纳金额
SELECT
r.rfq_id,
r.rfq_no,
r.rfq_title,
ri.total_expected AS expectedTotal,
COALESCE(q.total_amount, 0) AS acceptedQuote,
(ri.total_expected - COALESCE(q.total_amount, 0)) AS savedAmount
FROM biz_rfq r
JOIN (
SELECT rfq_id, SUM(quantity * expected_price) AS total_expected
FROM biz_rfq_item GROUP BY rfq_id
) ri ON r.rfq_id = ri.rfq_id
LEFT JOIN biz_quotation q ON r.rfq_id = q.rfq_id AND q.status = 'accepted'
WHERE r.status = 'completed';
```
#### 供应商中标率
```sql
SELECT
q.supplier_id,
s.supplier_name,
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;
```
### 6.2 性能优化建议
| 优化点 | 方案 |
|--------|------|
| **大数据量** | 报表查询加时间范围过滤默认只查最近12个月 |
| **缓存** | 看板数据用 Redis 缓存 5 分钟(利用 RuoYi 已有 Redis 集成) |
| **复杂统计** | 新建物化视图或定时任务汇总到统计表(后续优化) |
---
## 七、权限与菜单设计
### 7.1 权限标识
| 权限标识 | 对应页面 | 说明 |
|---------|---------|------|
| `bid:report:list` | 统计分析目录 | 父菜单显示 |
| `bid:report:dashboard` | 采购总览看板 | 仪表盘页面 |
| `bid:report:cost` | 采购成本分析 | 成本页面 |
| `bid:report:supplier` | 供应商绩效 | 供应商页面 |
### 7.2 权限分配
在系统管理 → 角色管理中,为管理员角色勾选新增的菜单权限:
- 统计分析 → 采购总览看板
- 统计分析 → 采购成本分析
- 统计分析 → 供应商绩效
---
## 八、实施步骤与里程碑
### Phase 1后端基础预计 1 天)
| 步骤 | 内容 | 文件 |
|------|------|------|
| 1.1 | 创建 3 个 VO 类 | `ReportDashboardVO.java`, `ReportCostVO.java`, `ReportSupplierVO.java` |
| 1.2 | 创建 Mapper 接口 + XML | `BizReportMapper.java`, `BizReportMapper.xml` |
| 1.3 | 创建 Service 接口 + 实现 | `IBizReportService.java`, `BizReportServiceImpl.java` |
| 1.4 | 创建 Controller | `BizReportController.java` |
| 1.5 | 测试 API | 使用 Swagger 或浏览器验证 JSON 返回 |
### Phase 2前端页面预计 1.5 天)
| 步骤 | 内容 | 文件 |
|------|------|------|
| 2.1 | 创建 API 封装 | `src/api/bid/report.js` |
| 2.2 | 搭建看板页面 | `dashboard.vue` + `KpiCard.vue` |
| 2.3 | 搭建成本分析页面 | `cost.vue` |
| 2.4 | 搭建供应商绩效页面 | `supplier.vue` |
| 2.5 | 集成 ECharts 图表 | 在各页面中初始化 |
### Phase 3集成上线预计 0.5 天)
| 步骤 | 内容 |
|------|------|
| 3.1 | 注册路由到 `router/index.js` |
| 3.2 | 执行菜单 SQL授权 admin 角色 |
| 3.3 | 前后端联调测试 |
| 3.4 | 展示给老板看,收集反馈 |
### 总计工期:**约 3 天**
---
## 九、文件清单与代码量预估
### 后端7 个文件)
| 文件 | 路径 | 预估行数 |
|------|------|---------|
| `BizReportController.java` | `ruoyi-admin/.../controller/bid/` | ~60 行 |
| `IBizReportService.java` | `ruoyi-system/.../service/bid/` | ~20 行 |
| `BizReportServiceImpl.java` | `ruoyi-system/.../service/bid/impl/` | ~120 行 |
| `BizReportMapper.java` | `ruoyi-system/.../mapper/bid/` | ~15 行 |
| `BizReportMapper.xml` | `ruoyi-system/.../mapper/bid/` | ~200 行 |
| 3 个 VO 类 | `ruoyi-system/.../domain/bid/` | ~300 行 |
| **小计** | | **~715 行** |
### 前端5 个文件)
| 文件 | 路径 | 预估行数 |
|------|------|---------|
| `report.js` | `src/api/bid/` | ~30 行 |
| `dashboard.vue` | `src/views/bid/report/` | ~350 行 |
| `cost.vue` | `src/views/bid/report/` | ~300 行 |
| `supplier.vue` | `src/views/bid/report/` | ~350 行 |
| `KpiCard.vue` | `src/views/bid/report/components/` | ~80 行 |
| **小计** | | **~1,110 行** |
### SQL 变更1 个脚本)
| 文件 | 用途 | 行数 |
|------|------|------|
| `report_menu.sql` | 菜单 SQL | ~20 行 |
### 总计:约 **12 个文件****~1,800 行代码**
---
## 附录:与老板汇报话术参考
> **老板,我做了一个统计分析模块,分为三个页面:**
>
> 1. **采购总览看板** — 一打开就能看到本月花了多少钱、发了多少询价单、供应商活跃情况,还配了趋势图和排名。
> 2. **采购成本分析** — 对比预算和实际花销,看哪些品类花钱多,每次比价节省了多少,一眼看清钱花在哪。
> 3. **供应商绩效** — 给每个供应商打分排名(质量/交期/服务/价格),哪家好哪家差一目了然,还有异常统计。
>
> **数据都来自我们系统里已有的业务数据,不需要额外录入。预计 3 天可以开发完成。**
---
> **文档版本:** v1.0
> **编写日期:** 2026-06-03
> **适用范围:** 福安德智慧报价平台