diff --git a/doc/智慧报价系统-统计报表模块开发方案.md b/doc/智慧报价系统-统计报表模块开发方案.md new file mode 100644 index 00000000..535f958b --- /dev/null +++ b/doc/智慧报价系统-统计报表模块开发方案.md @@ -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 monthlyTrend; // 月度采购趋势 + private List rfqStatusDist; // RFQ状态分布 + private List topSuppliers; // Top供应商排名 + private List 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; + + @Data + public static class CostTrend { + private String month; + private BigDecimal expectedAmount; + private BigDecimal actualAmount; + private BigDecimal savedAmount; + } + + // ====== 品类采购分布 ====== + private List categoryDist; + + @Data + public static class CategoryDist { + private String categoryName; + private BigDecimal amount; + private int materialCount; + private double percent; + } + + // ====== 单次RFQ比价详情 ====== + private List 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 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; + + @Data + public static class RadarData { + private String supplierName; + private double quality; + private double delivery; + private double service; + private double price; + } + + // ====== 月度评分趋势 ====== + private List scoreTrends; + + @Data + public static class ScoreTrend { + private String month; + private Long supplierId; + private String supplierName; + private double score; + } + + // ====== 异议统计 ====== + private List 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## 五、前端详细设计 + +### 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 + + + + + +``` + +### 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 +> **适用范围:** 福安德智慧报价平台 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizReportController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizReportController.java new file mode 100644 index 00000000..dadf7d6d --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/bid/BizReportController.java @@ -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("导出功能待实现"); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportCostVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportCostVO.java new file mode 100644 index 00000000..ba9e0513 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportCostVO.java @@ -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; + + /** 品类采购分布 */ + private List categoryDist; + + /** RFQ 比价明细 */ + private List rfqDetails; + + // ===== getters / setters ===== + + public CostSummary getSummary() { return summary; } + public void setSummary(CostSummary v) { summary = v; } + + public List getCostTrend() { return costTrend; } + public void setCostTrend(List v) { costTrend = v; } + + public List getCategoryDist() { return categoryDist; } + public void setCategoryDist(List v) { categoryDist = v; } + + public List getRfqDetails() { return rfqDetails; } + public void setRfqDetails(List 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; } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportDashboardVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportDashboardVO.java new file mode 100644 index 00000000..b4272fa8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportDashboardVO.java @@ -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 monthlyTrend; + + /** RFQ 状态分布 */ + private List rfqStatusDist; + + /** Top 供应商排名 */ + private List topSuppliers; + + /** 最近动态 */ + private List 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 getMonthlyTrend() { return monthlyTrend; } + public void setMonthlyTrend(List v) { monthlyTrend = v; } + + public List getRfqStatusDist() { return rfqStatusDist; } + public void setRfqStatusDist(List v) { rfqStatusDist = v; } + + public List getTopSuppliers() { return topSuppliers; } + public void setTopSuppliers(List v) { topSuppliers = v; } + + public List getRecentActivities() { return recentActivities; } + public void setRecentActivities(List 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; } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportSupplierVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportSupplierVO.java new file mode 100644 index 00000000..78b96e20 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bid/ReportSupplierVO.java @@ -0,0 +1,133 @@ +package com.ruoyi.system.domain.bid; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 供应商绩效 VO + */ +public class ReportSupplierVO { + + /** 评分排名 */ + private List rankings; + + /** 雷达图数据 */ + private List radarData; + + /** 中标率 */ + private List winRateData; + + /** 异议统计 */ + private List objectionStats; + + // ===== getters / setters ===== + + public List getRankings() { return rankings; } + public void setRankings(List v) { rankings = v; } + + public List getRadarData() { return radarData; } + public void setRadarData(List v) { radarData = v; } + + public List getWinRateData() { return winRateData; } + public void setWinRateData(List v) { winRateData = v; } + + public List getObjectionStats() { return objectionStats; } + public void setObjectionStats(List 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; } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizReportMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizReportMapper.java new file mode 100644 index 00000000..767483d0 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/bid/BizReportMapper.java @@ -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 selectPurchaseAmount(@Param("month") String month); + + /** 本月/上月 RFQ 数量 */ + Map selectRfqCount(@Param("month") String month); + + /** 本月/上月 PO 数量 */ + Map selectPoCount(@Param("month") String month); + + /** 活跃供应商数 */ + Map selectActiveSupplierCount(); + + /** 月度采购趋势(近12月) */ + List selectMonthlyTrend(); + + /** RFQ 状态分布 */ + List selectRfqStatusDist(); + + /** Top 供应商排名 */ + List selectTopSuppliers(); + + /** 最近动态 */ + List selectRecentActivities(); + + // ========== 成本分析 ========== + + /** 预算总额(所有已完成RFQ的期望价) */ + Map selectTotalExpected(); + + /** 实际采购总额 */ + Map selectTotalActual(); + + /** 月度成本趋势 */ + List selectCostTrend(@Param("startMonth") String startMonth, + @Param("endMonth") String endMonth); + + /** 品类采购分布 */ + List selectCategoryDist(); + + /** RFQ 比价明细 */ + List selectRfqCompareDetails(); + + // ========== 供应商绩效 ========== + + /** 供应商评分排名 */ + List selectSupplierScores(); + + /** 供应商中标率 */ + List selectWinRate(); + + /** 供应商雷达图数据 */ + List selectRadarData(); + + /** 异议统计 */ + List selectObjectionStats(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizReportService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizReportService.java new file mode 100644 index 00000000..35e16cdc --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/IBizReportService.java @@ -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(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizReportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizReportServiceImpl.java new file mode 100644 index 00000000..2e449a4e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/bid/impl/BizReportServiceImpl.java @@ -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 act = mapper.selectActiveSupplierCount(); + BigDecimal activeVal = toBD(act.get("totalCount")); + vo.setActiveSupplierCount(buildKpi("活跃供应商", activeVal, null, "家")); + + // 2) 月度趋势 + vo.setMonthlyTrend(mapper.selectMonthlyTrend()); + + // 3) RFQ 状态分布 + List 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 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 map = mapper.selectPurchaseAmount(month); + return toBD(map.get(column)); + } + + private BigDecimal selectCount(String type, String month) { + Map 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; + } +} diff --git a/ruoyi-system/src/main/resources/mapper/bid/BizReportMapper.xml b/ruoyi-system/src/main/resources/mapper/bid/BizReportMapper.xml new file mode 100644 index 00000000..6699a2c1 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/bid/BizReportMapper.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-ui/src/api/bid/report.js b/ruoyi-ui/src/api/bid/report.js new file mode 100644 index 00000000..c00ec847 --- /dev/null +++ b/ruoyi-ui/src/api/bid/report.js @@ -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' +}) diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index 702ec5be..ab83c42d 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -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, diff --git a/ruoyi-ui/src/views/bid/report/components/KpiCard.vue b/ruoyi-ui/src/views/bid/report/components/KpiCard.vue new file mode 100644 index 00000000..d3d03e39 --- /dev/null +++ b/ruoyi-ui/src/views/bid/report/components/KpiCard.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/ruoyi-ui/src/views/bid/report/cost.vue b/ruoyi-ui/src/views/bid/report/cost.vue new file mode 100644 index 00000000..6a9d2e28 --- /dev/null +++ b/ruoyi-ui/src/views/bid/report/cost.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/ruoyi-ui/src/views/bid/report/dashboard.vue b/ruoyi-ui/src/views/bid/report/dashboard.vue new file mode 100644 index 00000000..1d770646 --- /dev/null +++ b/ruoyi-ui/src/views/bid/report/dashboard.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/ruoyi-ui/src/views/bid/report/supplier.vue b/ruoyi-ui/src/views/bid/report/supplier.vue new file mode 100644 index 00000000..5ac91177 --- /dev/null +++ b/ruoyi-ui/src/views/bid/report/supplier.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/sql/check_data.sql b/sql/check_data.sql new file mode 100644 index 00000000..20d02b6b --- /dev/null +++ b/sql/check_data.sql @@ -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; diff --git a/sql/report_menu.sql b/sql/report_menu.sql new file mode 100644 index 00000000..26570096 --- /dev/null +++ b/sql/report_menu.sql @@ -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());