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
> **适用范围:** 福安德智慧报价平台

View File

@@ -0,0 +1,59 @@
package com.ruoyi.web.controller.bid;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.service.bid.IBizReportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 统计分析报表 Controller
*/
@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) String startMonth,
@RequestParam(required = false) 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 AjaxResult export(@PathVariable String type) {
// TODO: 使用 ExcelUtil 导出(后续可扩展)
return success("导出功能待实现");
}
}

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>

View File

@@ -0,0 +1,19 @@
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'
})

View File

@@ -125,6 +125,43 @@ export const dynamicRoutes = [
}
]
},
// ── 统计分析 路由 ──
{
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' }
}]
},
{
path: '/system/user-auth',
component: Layout,

View File

@@ -0,0 +1,104 @@
<template>
<el-card shadow="hover" class="kpi-card" :style="{ '--kpi-accent': accentColor }">
<div class="kpi-label">{{ label }}</div>
<div class="kpi-value">
<span class="kpi-number">{{ displayValue }}</span>
<span class="kpi-unit" v-if="unit">{{ unit }}</span>
</div>
<div class="kpi-trend" :class="trendClass" v-if="changeRate > 0">
<i :class="trendIcon"></i>
{{ changeRateText }}
<span class="trend-label">环比上月</span>
</div>
<div class="kpi-trend no-change" v-else>
<i class="el-icon-minus"></i> 持平
</div>
</el-card>
</template>
<script>
const COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
const CARD_NAMES = ['采购总额', 'RFQ总数', '采购单数', '活跃供应商']
export default {
name: 'KpiCard',
props: {
label: { type: String, required: true },
value: { type: [Number, String], default: 0 },
unit: { type: String, default: '' },
changeRate: { type: Number, default: 0 },
trend: { type: String, default: 'up' }
},
computed: {
safeValue() {
const v = Number(this.value)
return isNaN(v) ? 0 : v
},
displayValue() {
const v = this.safeValue
if (this.label === '采购总额') {
if (v >= 100000000) return '¥' + (v / 100000000).toFixed(2) + '亿'
if (v >= 10000) return '¥' + (v / 10000).toFixed(2) + '万'
return '¥' + v.toLocaleString()
}
return v.toLocaleString()
},
trendClass() { return this.trend === 'up' ? 'trend-up' : 'trend-down' },
trendIcon() { return this.trend === 'up' ? 'el-icon-top' : 'el-icon-bottom' },
changeRateText() {
const rate = Number(this.changeRate) || 0
return rate.toFixed(1) + '%'
},
accentColor() {
const idx = CARD_NAMES.indexOf(this.label)
return COLORS[idx >= 0 ? idx : 4]
}
}
}
</script>
<style scoped>
.kpi-card {
border-left: 4px solid var(--kpi-accent, #409EFF);
border-radius: 6px;
transition: transform .2s;
}
.kpi-card:hover {
transform: translateY(-2px);
}
.kpi-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.kpi-value {
margin-bottom: 8px;
display: flex;
align-items: baseline;
}
.kpi-number {
font-size: 28px;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.kpi-unit {
font-size: 14px;
color: #909399;
margin-left: 4px;
}
.kpi-trend {
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
}
.trend-up { color: #67C23A; }
.trend-down { color: #F56C6C; }
.no-change { color: #C0C4CC; }
.trend-label {
color: #C0C4CC;
margin-left: 4px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="app-container cost-page">
<div class="page-header">
<span class="page-title">💰 采购成本分析</span>
</div>
<div v-if="loading" class="loading-box">
<i class="el-icon-loading"></i> 加载中
</div>
<div v-show="!loading && data" class="cost-content">
<el-row :gutter="20" class="summary-row">
<el-col :xs="8" :sm="6" v-for="s in summaryCards" :key="s.label" style="margin-bottom:16px">
<el-card shadow="hover" class="summary-card" :style="{ '--s-accent': s.color }">
<div class="s-label">{{ s.label }}</div>
<div class="s-value" :style="{ color: s.color }">{{ s.value }}</div>
<div class="s-sub" v-if="s.sub">{{ s.sub }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover" style="margin-bottom:16px">
<div slot="header" class="card-header">
<span><i class="el-icon-data-board" style="color:#409EFF"></i> 月度预算 vs 实际成本</span>
</div>
<div ref="costTrendChart" style="height:350px;width:100%"></div>
</el-card>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-s-data" style="color:#E6A23C"></i> 品类采购分布</span>
</div>
<div ref="categoryChart" style="height:300px;width:100%"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-success" style="color:#67C23A"></i> 节省分析</span>
</div>
<div ref="savedChart" style="height:300px;width:100%"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-document" style="color:#909399"></i> RFQ 比价明细</span>
</div>
<el-table :data="data.rfqDetails || []" border size="small">
<el-table-column label="询价单号" prop="rfqNo" width="150" />
<el-table-column label="询价标题" prop="rfqTitle" min-width="180" show-overflow-tooltip />
<el-table-column label="预算价" width="130" align="right">
<template slot-scope="s"><span class="cell-expected">¥{{ formatMoney(s.row.expectedTotal) }}</span></template>
</el-table-column>
<el-table-column label="最低报价" width="130" align="right">
<template slot-scope="s"><span class="cell-lowest">¥{{ formatMoney(s.row.lowestQuote) }}</span></template>
</el-table-column>
<el-table-column label="采纳价格" width="130" align="right">
<template slot-scope="s"><span class="cell-actual">¥{{ formatMoney(s.row.acceptedQuote) }}</span></template>
</el-table-column>
<el-table-column label="节省金额" width="130" align="right">
<template slot-scope="s">
<span :class="Number(s.row.savedAmount) > 0 ? 'cell-saved' : 'cell-none'">
¥{{ formatMoney(s.row.savedAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="参与供应商" width="100" align="center" prop="supplierCount" />
<el-table-column label="操作" width="80" align="center">
<template slot-scope="s">
<el-button type="text" size="mini" icon="el-icon-data-analysis"
@click="goCompare(s.row.rfqId)">比价</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!data.rfqDetails || !data.rfqDetails.length" class="no-data" style="padding:20px">暂无数据</div>
</el-card>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import { debounce } from '@/utils'
import { getCostAnalysis } from '@/api/bid/report'
export default {
name: 'ReportCost',
data() {
return {
loading: false,
data: null,
trendChart: null,
categoryChart: null,
savedChart: null
}
},
computed: {
summaryCards() {
if (!this.data || !this.data.summary) return []
const s = this.data.summary
return [
{ label: '预算总额', value: '¥' + this.formatMoney(s.totalExpected), color: '#409EFF' },
{ label: '实际采购', value: '¥' + this.formatMoney(s.totalActual), color: '#67C23A' },
{ label: '节省金额', value: '¥' + this.formatMoney(s.savedAmount), color: Number(s.savedAmount) > 0 ? '#E6A23C' : '#C0C4CC', sub: Number(s.savedAmount) > 0 ? '为您节省了开支' : '' },
{ label: '节省比例', value: (Number(s.savedRate) || 0) + '%', color: '#F56C6C', sub: '相比预算' }
]
}
},
mounted() {
this.loadData()
this.__resizeHandler = debounce(() => {
this.trendChart && this.trendChart.resize()
this.categoryChart && this.categoryChart.resize()
this.savedChart && this.savedChart.resize()
}, 100)
window.addEventListener('resize', this.__resizeHandler)
this.$watch('$store.state.sidebar.opened', () => {
setTimeout(() => {
this.trendChart && this.trendChart.resize()
this.categoryChart && this.categoryChart.resize()
this.savedChart && this.savedChart.resize()
}, 300)
})
},
beforeDestroy() {
window.removeEventListener('resize', this.__resizeHandler)
this.trendChart && this.trendChart.dispose()
this.categoryChart && this.categoryChart.dispose()
this.savedChart && this.savedChart.dispose()
},
methods: {
loadData() {
this.loading = true
getCostAnalysis().then(r => {
this.data = r.data
this.loading = false
this.$nextTick(() => {
requestAnimationFrame(() => {
this.initTrendChart()
this.initCategoryChart()
this.initSavedChart()
})
})
}).catch(() => {
this.loading = false
this.$message.error('加载成本数据失败')
})
},
initTrendChart() {
const el = this.$refs.costTrendChart
if (!el) return
if (this.trendChart) this.trendChart.dispose()
this.trendChart = echarts.init(el, 'macarons')
const items = this.data.costTrend || []
this.trendChart.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
let s = '<strong>' + params[0].axisValue + '</strong><br/>'
params.forEach(p => {
s += p.marker + ' ' + p.seriesName + ':¥' + Number(p.value).toLocaleString() + '<br/>'
})
return s
}
},
legend: { data: ['预算金额', '实际金额'], left: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: items.map(d => d.month || '') },
yAxis: { type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
series: [
{
name: '预算金额', type: 'bar',
data: items.map(d => Number(d.expectedAmount) || 0),
itemStyle: { color: '#C0C4CC', borderRadius: [4,4,0,0] },
barWidth: '35%'
},
{
name: '实际金额', type: 'bar',
data: items.map(d => Number(d.actualAmount) || 0),
itemStyle: { color: '#409EFF', borderRadius: [4,4,0,0] },
barWidth: '35%',
label: { show: true, position: 'top', formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
}
]
})
},
initCategoryChart() {
const el = this.$refs.categoryChart
if (!el) return
if (this.categoryChart) this.categoryChart.dispose()
this.categoryChart = echarts.init(el, 'macarons')
const list = (this.data.categoryDist || []).map(d => ({
name: d.categoryName || '未分类',
value: Number(d.amount) || 0
}))
this.categoryChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: ¥{c} ({d}%)' },
series: [{
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '55%'],
label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 },
data: list.length ? list : [{ name: '暂无', value: 1 }],
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#22a4ff']
}]
})
},
initSavedChart() {
const el = this.$refs.savedChart
if (!el) return
if (this.savedChart) this.savedChart.dispose()
this.savedChart = echarts.init(el, 'macarons')
const items = this.data.costTrend || []
this.savedChart.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
return '<strong>' + params[0].axisValue + '</strong><br/>'
+ '节省:¥' + Number(params[0].value).toLocaleString()
}
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: items.map(d => d.month || '') },
yAxis: { type: 'value', name: '节省(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
series: [{
type: 'line',
data: items.map(d => Number(d.savedAmount) || 0),
itemStyle: { color: '#67C23A' },
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103,194,58,0.4)' },
{ offset: 1, color: 'rgba(103,194,58,0.05)' }
])},
lineStyle: { width: 3 },
symbol: 'diamond', symbolSize: 10,
label: { show: true, formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
}]
})
},
goCompare(rfqId) {
this.$router.push({ path: '/bid/comparison/detail', query: { rfqId } })
},
formatMoney(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
}
}
</script>
<style lang="scss" scoped>
.cost-page { padding-bottom: 30px; }
.page-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
}
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
.cost-content { min-height: 400px; }
.summary-card {
border-left: 4px solid var(--s-accent, #409EFF);
border-radius: 6px;
.s-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
.s-value { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.s-sub { font-size: 12px; color: #C0C4CC; }
}
.cell-expected { color: #C0C4CC; }
.cell-lowest { color: #E6A23C; font-weight: 600; }
.cell-actual { color: #409EFF; font-weight: 600; }
.cell-saved { color: #67C23A; font-weight: 700; }
.cell-none { color: #C0C4CC; }
.no-data { text-align: center; color: #C0C4CC; }
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div class="app-container dashboard-page">
<div class="page-header">
<span class="page-title">📊 采购总览看板</span>
<span class="page-tip">实时数据 · 自动更新</span>
</div>
<div v-if="loading" class="loading-box">
<i class="el-icon-loading"></i> 加载中
</div>
<div v-show="!loading && data" class="dashboard-content">
<el-row :gutter="20" class="kpi-row">
<el-col :xs="12" :sm="6" v-for="card in kpiCards" :key="card.label" style="margin-bottom:16px">
<KpiCard v-bind="card" />
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="14" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-data-line" style="color:#409EFF"></i> 月度采购趋势</span>
</div>
<div ref="trendChart" style="height:320px;width:100%"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="10" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-pie-chart" style="color:#67C23A"></i> RFQ 状态分布</span>
</div>
<div ref="pieChart" style="height:320px;width:100%"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-s-order" style="color:#E6A23C"></i> 供应商采购排名 Top5</span>
</div>
<div ref="rankChart" style="height:280px;width:100%"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-timer" style="color:#909399"></i> 最新动态</span>
</div>
<div class="activity-list" v-if="data.recentActivities && data.recentActivities.length">
<div v-for="(act, i) in data.recentActivities" :key="i" class="activity-item">
<span class="act-time">{{ act.time }}</span>
<el-tag :type="actTag(act.type)" size="mini">{{ actType(act.type) }}</el-tag>
<span class="act-desc">{{ act.desc }}</span>
</div>
</div>
<div v-else class="no-data">暂无动态</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import { debounce } from '@/utils'
import { getDashboard } from '@/api/bid/report'
import KpiCard from './components/KpiCard'
export default {
name: 'ReportDashboard',
components: { KpiCard },
data() {
return {
loading: false,
data: null,
trendChart: null,
pieChart: null,
rankChart: null
}
},
computed: {
kpiCards() {
if (!this.data) return []
const map = {
'采购总额': 'totalPurchaseAmount',
'RFQ总数': 'totalRfqCount',
'采购单数': 'totalPoCount',
'活跃供应商': 'activeSupplierCount'
}
return Object.entries(map).map(([label, key]) => {
const item = this.data[key]
return {
label,
value: item && item.value !== undefined && item.value !== null ? item.value : 0,
unit: item ? item.unit : '',
changeRate: item ? item.changeRate : 0,
trend: item ? item.trend : 'up'
}
})
}
},
mounted() {
this.loadData()
this.__resizeHandler = debounce(() => {
this.trendChart && this.trendChart.resize()
this.pieChart && this.pieChart.resize()
this.rankChart && this.rankChart.resize()
}, 100)
window.addEventListener('resize', this.__resizeHandler)
// 监听侧边栏变化
this.$watch('$store.state.sidebar.opened', () => {
setTimeout(() => {
this.trendChart && this.trendChart.resize()
this.pieChart && this.pieChart.resize()
this.rankChart && this.rankChart.resize()
}, 300)
})
},
beforeDestroy() {
window.removeEventListener('resize', this.__resizeHandler)
this.trendChart && this.trendChart.dispose()
this.pieChart && this.pieChart.dispose()
this.rankChart && this.rankChart.dispose()
},
methods: {
loadData() {
this.loading = true
getDashboard().then(r => {
this.data = r.data
this.loading = false
this.$nextTick(() => {
requestAnimationFrame(() => {
this.initTrendChart()
this.initPieChart()
this.initRankChart()
})
})
}).catch(() => {
this.loading = false
this.$message.error('加载看板数据失败')
})
},
initTrendChart() {
const el = this.$refs.trendChart
if (!el) return
if (this.trendChart) this.trendChart.dispose()
this.trendChart = echarts.init(el, 'macarons')
const list = this.data.monthlyTrend || []
this.trendChart.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
let s = '<strong>' + params[0].axisValue + '</strong><br/>'
params.forEach(p => {
if (p.seriesIndex === 0) s += p.marker + ' 金额:¥' + Number(p.value).toLocaleString() + '<br/>'
else s += p.marker + ' 单数:' + p.value + ' 单<br/>'
})
return s
}
},
legend: { data: ['采购金额', '采购单数'], left: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: list.map(d => d.month || ''), axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
{ type: 'value', name: '单数' }
],
series: [
{
name: '采购金额', type: 'bar', data: list.map(d => Number(d.amount) || 0),
itemStyle: { color: '#409EFF', borderRadius: [4,4,0,0] },
barWidth: '40%',
label: { show: true, position: 'top', formatter: p => '¥' + Number(p.value).toLocaleString(), fontSize: 10 }
},
{
name: '采购单数', type: 'line', yAxisIndex: 1, data: list.map(d => Number(d.count) || 0),
itemStyle: { color: '#67C23A' },
lineStyle: { width: 3 },
symbol: 'circle', symbolSize: 8
}
]
})
},
initPieChart() {
const el = this.$refs.pieChart
if (!el) return
if (this.pieChart) this.pieChart.dispose()
this.pieChart = echarts.init(el, 'macarons')
const list = (this.data.rfqStatusDist || []).map(d => ({
name: d.statusLabel || d.status || '未知',
value: Number(d.count) || 0
}))
this.pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} 单 ({d}%)' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '55%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: list.length ? list : [{ name: '暂无数据', value: 1 }],
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
}]
})
},
initRankChart() {
const el = this.$refs.rankChart
if (!el) return
if (this.rankChart) this.rankChart.dispose()
this.rankChart = echarts.init(el, 'macarons')
const items = this.data.topSuppliers || []
if (!items.length) {
this.rankChart.setOption({
title: { text: '暂无采购数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
})
return
}
const names = items.map(d => d.supplierName || '未知')
const amounts = items.map(d => Number(d.totalAmount) || 0)
this.rankChart.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
const idx = params[0].dataIndex
const item = items[idx]
if (!item) return ''
return `<strong>${item.supplierName}</strong><br/>
采购金额:¥${Number(item.totalAmount).toLocaleString()}<br/>
采购次数:${item.poCount || 0} 次<br/>
综合评分:${(Number(item.avgScore) || 0).toFixed(1)}`
}
},
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
xAxis: { type: 'value', name: '金额(元)', axisLabel: { formatter: v => v >= 10000 ? (v/10000)+'万' : v } },
yAxis: { type: 'category', data: names.slice().reverse(), axisLabel: { fontSize: 12 } },
series: [{
type: 'bar',
data: amounts.slice().reverse().map(v => ({
value: v,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409EFF' },
{ offset: 1, color: '#22a4ff' }
]),
borderRadius: [0, 6, 6, 0]
}
})),
barWidth: '55%',
label: {
show: true,
position: 'right',
formatter: p => '¥' + Number(p.value).toLocaleString(),
fontSize: 11
}
}]
})
},
actTag(type) {
return { PO: 'primary', QUOTE: 'success', EVAL: 'warning', OBJECTION: 'danger' }[type] || 'info'
},
actType(type) {
return { PO: '采购单', QUOTE: '报价', EVAL: '评价', OBJECTION: '异议' }[type] || type
}
}
}
</script>
<style lang="scss" scoped>
.dashboard-page { padding-bottom: 30px; }
.page-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
}
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
.page-tip { font-size: 13px; color: #C0C4CC; }
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
.kpi-row { margin-bottom: 4px; }
.dashboard-content { min-height: 400px; }
.activity-list { max-height: 280px; overflow-y: auto; }
.activity-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
&:last-child { border-bottom: none; }
}
.act-time { font-size: 12px; color: #C0C4CC; white-space: nowrap; min-width: 120px; }
.act-desc { font-size: 13px; color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.no-data { padding: 40px 0; text-align: center; color: #C0C4CC; }
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div class="app-container supplier-page">
<div class="page-header">
<span class="page-title">🏆 供应商绩效</span>
</div>
<div v-if="loading" class="loading-box">
<i class="el-icon-loading"></i> 加载中
</div>
<div v-show="!loading && data" class="supplier-content">
<el-card shadow="hover" style="margin-bottom:16px">
<div slot="header" class="card-header">
<span><i class="el-icon-s-custom" style="color:#409EFF"></i> 供应商综合评分排名</span>
</div>
<el-table :data="data.rankings || []" border size="small"
highlight-current-row @current-change="onRowClick" style="cursor:pointer">
<el-table-column label="排名" type="index" width="55" align="center" />
<el-table-column label="供应商名称" prop="supplierName" min-width="160" />
<el-table-column label="评价次数" prop="evalCount" width="80" align="center" />
<el-table-column label="质量评分" width="90" align="center">
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.qualityAvg))" size="mini">{{ safeFixed(s.row.qualityAvg, 1) }}</el-tag></template>
</el-table-column>
<el-table-column label="交期评分" width="90" align="center">
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.deliveryAvg))" size="mini">{{ safeFixed(s.row.deliveryAvg, 1) }}</el-tag></template>
</el-table-column>
<el-table-column label="服务评分" width="90" align="center">
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.serviceAvg))" size="mini">{{ safeFixed(s.row.serviceAvg, 1) }}</el-tag></template>
</el-table-column>
<el-table-column label="价格评分" width="90" align="center">
<template slot-scope="s"><el-tag :type="scoreTag(Number(s.row.priceAvg))" size="mini">{{ safeFixed(s.row.priceAvg, 1) }}</el-tag></template>
</el-table-column>
<el-table-column label="综合评分" width="100" align="center">
<template slot-scope="s">
<span :class="'score-badge-' + scoreClass(s.row.totalAvg)">{{ safeFixed(s.row.totalAvg, 1) }}</span>
</template>
</el-table-column>
<el-table-column label="采购次数" prop="poCount" width="80" align="center" />
<el-table-column label="采购金额" width="130" align="right">
<template slot-scope="s">¥{{ formatMoney(s.row.poAmount) }}</template>
</el-table-column>
</el-table>
<div v-if="!data.rankings || !data.rankings.length" class="no-data">暂无数据</div>
</el-card>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-s-marketing" style="color:#409EFF"></i> 供应商评价雷达图</span>
<span style="float:right;font-size:12px;color:#C0C4CC">点击表格行切换</span>
</div>
<div ref="radarChart" style="height:320px;width:100%"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" style="margin-bottom:16px">
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-data-board" style="color:#67C23A"></i> 中标率分析</span>
</div>
<div ref="winRateChart" style="height:320px;width:100%"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover">
<div slot="header" class="card-header">
<span><i class="el-icon-warning-outline" style="color:#F56C6C"></i> 订单异议统计</span>
</div>
<el-table :data="data.objectionStats || []" border size="small">
<el-table-column label="供应商" prop="supplierName" min-width="160" />
<el-table-column label="异议数" prop="objectionCount" width="90" align="center" />
<el-table-column label="已解决" prop="resolvedCount" width="80" align="center" />
<el-table-column label="解决率" width="100" align="center">
<template slot-scope="s">
<el-progress :percentage="resolveRate(s.row)" :stroke-width="12" :show-text="true" />
</template>
</el-table-column>
<el-table-column label="主要问题" prop="topReason" min-width="200" show-overflow-tooltip />
</el-table>
<div v-if="!data.objectionStats || !data.objectionStats.length" class="no-data" style="padding:20px">
暂无订单异议记录
</div>
</el-card>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
require('echarts/theme/macarons') // echarts theme
import { debounce } from '@/utils'
import { getSupplierPerformance } from '@/api/bid/report'
export default {
name: 'ReportSupplier',
data() {
return {
loading: false,
data: null,
radarChart: null,
winRateChart: null
}
},
mounted() {
this.loadData()
this.__resizeHandler = debounce(() => {
this.radarChart && this.radarChart.resize()
this.winRateChart && this.winRateChart.resize()
}, 100)
window.addEventListener('resize', this.__resizeHandler)
this.$watch('$store.state.sidebar.opened', () => {
setTimeout(() => {
this.radarChart && this.radarChart.resize()
this.winRateChart && this.winRateChart.resize()
}, 300)
})
},
beforeDestroy() {
window.removeEventListener('resize', this.__resizeHandler)
this.radarChart && this.radarChart.dispose()
this.winRateChart && this.winRateChart.dispose()
},
methods: {
loadData() {
this.loading = true
getSupplierPerformance().then(r => {
this.data = r.data
this.loading = false
this.$nextTick(() => {
requestAnimationFrame(() => {
this.initRadarChart()
this.initWinRateChart()
})
})
}).catch(() => {
this.loading = false
this.$message.error('加载供应商数据失败')
})
},
// ── 雷达图 ──
initRadarChart(supplierName) {
const el = this.$refs.radarChart
if (!el) return
if (this.radarChart) {
this.radarChart.dispose()
this.radarChart = null
}
this.radarChart = echarts.init(el, 'macarons')
let radarRows = this.data.radarData || []
if (supplierName) {
radarRows = radarRows.filter(r => r.supplierName === supplierName)
}
if (!radarRows.length) {
this.radarChart.setOption({
title: { text: '暂无评价数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
})
return
}
const indicator = [
{ name: '质量', max: 5 },
{ name: '交期', max: 5 },
{ name: '服务', max: 5 },
{ name: '价格', max: 5 }
]
const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#22a4ff']
this.radarChart.setOption({
legend: {
data: radarRows.map(r => r.supplierName),
top: 0,
textStyle: { fontSize: 11 }
},
radar: {
indicator,
center: ['50%', '58%'],
radius: '60%',
shape: 'circle',
name: { textStyle: { fontSize: 13, fontWeight: 600 } },
splitArea: { areaStyle: { color: ['rgba(64,158,255,0.02)', 'rgba(64,158,255,0.05)'] } }
},
series: [{
type: 'radar',
data: radarRows.map((r, i) => ({
name: r.supplierName,
value: [Number(r.quality) || 0, Number(r.delivery) || 0, Number(r.service) || 0, Number(r.price) || 0],
lineStyle: { color: colors[i % colors.length], width: 2 },
areaStyle: { color: colors[i % colors.length], opacity: 0.1 },
itemStyle: { color: colors[i % colors.length] }
}))
}]
})
},
// ── 中标率柱状图 ──
initWinRateChart() {
const el = this.$refs.winRateChart
if (!el) return
if (this.winRateChart) {
this.winRateChart.dispose()
this.winRateChart = null
}
this.winRateChart = echarts.init(el, 'macarons')
const items = this.data.winRateData || []
if (!items.length) {
this.winRateChart.setOption({
title: { text: '暂无报价数据', left: 'center', top: 'center', textStyle: { color: '#C0C4CC', fontSize: 14 } }
})
return
}
const names = items.map(d => d.supplierName || '')
const total = items.map(d => Number(d.totalQuotes) || 0)
const win = items.map(d => Number(d.winCount) || 0)
this.winRateChart.setOption({
tooltip: {
trigger: 'axis',
formatter: function(params) {
const idx = params[0].dataIndex
const item = items[idx]
if (!item) return ''
return `<strong>${item.supplierName}</strong><br/>
报价次数:${Number(item.totalQuotes) || 0} 次<br/>
中标次数:${Number(item.winCount) || 0} 次<br/>
中标率:${item.winRate || 0}%`
}
},
legend: { data: ['报价次数', '中标次数'], left: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { fontSize: 11, rotate: 15 } },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '报价次数', type: 'bar',
data: total,
itemStyle: { color: '#C0C4CC', borderRadius: [4,4,0,0] },
barWidth: '30%'
},
{
name: '中标次数', type: 'bar',
data: win,
itemStyle: { color: '#67C23A', borderRadius: [4,4,0,0] },
barWidth: '30%',
label: { show: true, position: 'top', formatter: p => items[p.dataIndex] ? (items[p.dataIndex].winRate || 0) + '%' : '' }
}
]
})
},
// ── 点击表格行切换雷达图 ──
onRowClick(row) {
if (row && row.supplierName) {
this.$nextTick(() => {
requestAnimationFrame(() => this.initRadarChart(row.supplierName))
})
}
},
resolveRate(row) {
if (!row || !row.objectionCount) return 0
return Math.round((Number(row.resolvedCount) || 0) / Number(row.objectionCount) * 100)
},
// ── 安全工具函数 ──
safeFixed(v, digits) {
const n = Number(v)
return isNaN(n) ? '0.0' : n.toFixed(digits)
},
scoreTag(v) {
const n = Number(v)
if (isNaN(n)) return 'info'
if (n >= 4.5) return 'success'
if (n >= 3.5) return 'primary'
if (n >= 2.5) return 'warning'
return 'danger'
},
scoreClass(v) {
const n = Number(v)
if (isNaN(n)) return 'low'
if (n >= 4.0) return 'high'
if (n >= 3.0) return 'mid'
return 'low'
},
formatMoney(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
}
}
</script>
<style lang="scss" scoped>
.supplier-page { padding-bottom: 30px; }
.page-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #f0f2f5;
}
.page-title { font-size: 20px; font-weight: 700; color: #1a2c4e; }
.loading-box { text-align: center; padding: 120px 0; color: #909399; font-size: 15px; i { font-size: 24px; margin-right: 8px; } }
.card-header { font-weight: 600; color: #303133; font-size: 14px; }
.supplier-content { min-height: 400px; }
.no-data { text-align: center; color: #C0C4CC; padding: 40px 0; }
.score-badge-high { color: #67C23A; font-weight: 700; font-size: 16px; }
.score-badge-mid { color: #E6A23C; font-weight: 600; font-size: 15px; }
.score-badge-low { color: #F56C6C; font-weight: 600; }
</style>

15
sql/check_data.sql Normal file
View File

@@ -0,0 +1,15 @@
SELECT '=== 供应商评价 ===' AS info;
SELECT e.*, s.supplier_name
FROM biz_supplier_evaluation e
LEFT JOIN biz_supplier s ON e.supplier_id = s.supplier_id;
SELECT '=== 报价状态分布 ===' AS info;
SELECT status, COUNT(*) FROM biz_quotation GROUP BY status;
SELECT '=== 供应商报价参与统计 ===' AS info;
SELECT q.supplier_id, s.supplier_name, COUNT(*) AS cnt,
SUM(CASE WHEN q.status='accepted' THEN 1 ELSE 0 END) AS accepted_cnt
FROM biz_quotation q
LEFT JOIN biz_supplier s ON q.supplier_id = s.supplier_id
GROUP BY q.supplier_id, s.supplier_name
ORDER BY q.supplier_id;

19
sql/report_menu.sql Normal file
View File

@@ -0,0 +1,19 @@
-- ═══════════════════════════════════════════════════════════════════
-- 统计分析模块 — 菜单 SQL正确ID版本
-- ═══════════════════════════════════════════════════════════════════
-- 父菜单:统计分析 (2019)
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(2019, '统计分析', 2000, 11, 'report', NULL, 1, 0, 'M', '0', '0', 'bid:report:list', 'chart', 'admin', NOW());
-- 子菜单:采购总览看板 (2020)
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(2020, '采购总览看板', 2019, 1, 'dashboard', 'bid/report/dashboard', 1, 0, 'C', '0', '0', 'bid:report:dashboard', 'dashboard', 'admin', NOW());
-- 子菜单:采购成本分析 (2021)
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(2021, '采购成本分析', 2019, 2, 'cost', 'bid/report/cost', 1, 0, 'C', '0', '0', 'bid:report:cost', 'money', 'admin', NOW());
-- 子菜单:供应商绩效 (2022)
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(2022, '供应商绩效', 2019, 3, 'supplier', 'bid/report/supplier', 1, 0, 'C', '0', '0', 'bid:report:supplier', 'star', 'admin', NOW());