feat(bid): 新增投标报表统计分析模块
本次提交新增了完整的投标报表统计分析功能,包括: 添加用于数据检查与菜单初始化的 SQL 脚本 实现采购概览仪表板、采购成本分析及供应商绩效报告的后端服务、Mapper、Controller 及 VO 类 添加前端 API、路由配置以及使用 ECharts 可视化图表的页面组件 为仪表板添加通用的 KPI 卡片组件
This commit is contained in:
987
doc/智慧报价系统-统计报表模块开发方案.md
Normal file
987
doc/智慧报价系统-统计报表模块开发方案.md
Normal 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
|
||||
> **适用范围:** 福安德智慧报价平台
|
||||
@@ -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("导出功能待实现");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
325
ruoyi-system/src/main/resources/mapper/bid/BizReportMapper.xml
Normal file
325
ruoyi-system/src/main/resources/mapper/bid/BizReportMapper.xml
Normal 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 <= #{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>
|
||||
19
ruoyi-ui/src/api/bid/report.js
Normal file
19
ruoyi-ui/src/api/bid/report.js
Normal 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'
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
104
ruoyi-ui/src/views/bid/report/components/KpiCard.vue
Normal file
104
ruoyi-ui/src/views/bid/report/components/KpiCard.vue
Normal 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>
|
||||
278
ruoyi-ui/src/views/bid/report/cost.vue
Normal file
278
ruoyi-ui/src/views/bid/report/cost.vue
Normal 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>
|
||||
296
ruoyi-ui/src/views/bid/report/dashboard.vue
Normal file
296
ruoyi-ui/src/views/bid/report/dashboard.vue
Normal 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>
|
||||
307
ruoyi-ui/src/views/bid/report/supplier.vue
Normal file
307
ruoyi-ui/src/views/bid/report/supplier.vue
Normal 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
15
sql/check_data.sql
Normal 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
19
sql/report_menu.sql
Normal 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());
|
||||
Reference in New Issue
Block a user