Files
erp-next/doc/智慧报价系统-统计报表模块开发方案.md
王文昊 ba74618bea feat(bid): 新增投标报表统计分析模块
本次提交新增了完整的投标报表统计分析功能,包括:

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

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

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

为仪表板添加通用的 KPI 卡片组件
2026-06-03 14:26:25 +08:00

988 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 智慧报价系统 — 统计报表模块开发方案
> 基于 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
> **适用范围:** 福安德智慧报价平台