Explorar el Código

feat(wm-bpm+frontend): #34 流程统计评估完整实现

后端 (wm-bpm):
- ProcessStatisticsService: 统计聚合(平均处理时长/各节点耗时分布/超时流程列表/流程完成率/瓶颈节点识别/按时间范围统计/按流程定义分组)
- ProcessStatisticsController: /api/bpm/statistics/* 8个接口
- ProcessStatisticsMapper: 聚合查询/分组统计/超时筛选SQL
- DTOs: ProcessStatsOverview, NodeDurationVO, TimeoutProcessVO

前端 (frontend):
- ProcessStatsView.vue: ECharts统计仪表盘
  - 概览卡片(总流程数/运行中/已完成/超时数/平均时长/完成率)
  - 平均处理时长趋势折线图(含超时线标记)
  - 节点耗时分布柱状图(瓶颈节点标红)
  - 流程完成率饼图
  - 超时流程列表(高亮+跳转详情)
  - 各流程类型统计表格
- bpmStatsApi.ts: TypeScript API封装(8个接口+完整类型定义)
- 路由注册: /bpm/statistics

测试:
- ProcessStatisticsServiceTest: 11个测试用例覆盖核心逻辑
- 超时判定:单节点审批超过48小时视为超时
bot_dev2 hace 5 días
padre
commit
9c1cf0f7e4

+ 123
- 0
frontend/src/api/bpmStatsApi.ts Ver fichero

@@ -0,0 +1,123 @@
1
+import request from './request'
2
+
3
+const BASE = '/api/bpm/statistics'
4
+
5
+/** 统计概览 */
6
+export interface ProcessStatsOverview {
7
+  totalInstances: number
8
+  runningCount: number
9
+  completedCount: number
10
+  rejectedCount: number
11
+  terminatedCount: number
12
+  timeoutCount: number
13
+  suspendedCount: number
14
+  avgDurationHours: number
15
+  completionRate: number
16
+}
17
+
18
+/** 节点耗时分布 */
19
+export interface NodeDurationVO {
20
+  nodeId: string
21
+  nodeName: string
22
+  avgDurationHours: number
23
+  maxDurationHours: number
24
+  minDurationHours: number
25
+  approvalCount: number
26
+  isBottleneck: boolean
27
+}
28
+
29
+/** 超时流程 */
30
+export interface TimeoutProcessVO {
31
+  id: number
32
+  instanceId: string
33
+  processName: string
34
+  title: string
35
+  initiatorName: string
36
+  currentNodeName: string
37
+  currentAssigneeName: string
38
+  startedAt: string
39
+  durationHours: number
40
+  status: string
41
+  priority: number
42
+}
43
+
44
+/** 趋势数据点 */
45
+export interface TrendDataPoint {
46
+  date: string
47
+  completedCount: number
48
+  avgHours: number
49
+  maxHours: number
50
+  minHours: number
51
+}
52
+
53
+/** 完成率 */
54
+export interface CompletionRateItem {
55
+  processKey: string
56
+  processName: string
57
+  total: number
58
+  completed: number
59
+  completionRate: number
60
+}
61
+
62
+/** 按流程定义统计 */
63
+export interface DefinitionStatItem {
64
+  definitionId: number
65
+  processName: string
66
+  processKey: string
67
+  totalInstances: number
68
+  runningCount: number
69
+  completedCount: number
70
+  rejectedCount: number
71
+  terminatedCount: number
72
+  avgDurationHours: number
73
+  maxDurationHours: number
74
+  minDurationHours: number
75
+}
76
+
77
+// ========== API 调用 ==========
78
+
79
+/** 获取统计概览 */
80
+export function getOverview() {
81
+  return request.get<any, { data: ProcessStatsOverview }>(`${BASE}/overview`)
82
+}
83
+
84
+/** 获取平均处理时长趋势 */
85
+export function getAvgDurationTrend(days = 30) {
86
+  return request.get<any, { data: TrendDataPoint[] }>(`${BASE}/avg-duration-trend`, {
87
+    params: { days }
88
+  })
89
+}
90
+
91
+/** 获取节点耗时分布 */
92
+export function getNodeDurationDistribution() {
93
+  return request.get<any, { data: NodeDurationVO[] }>(`${BASE}/node-duration`)
94
+}
95
+
96
+/** 获取超时流程列表 */
97
+export function getTimeoutProcessList() {
98
+  return request.get<any, { data: TimeoutProcessVO[] }>(`${BASE}/timeout`)
99
+}
100
+
101
+/** 获取流程完成率 */
102
+export function getCompletionRate() {
103
+  return request.get<any, { data: CompletionRateItem[] }>(`${BASE}/completion-rate`)
104
+}
105
+
106
+/** 按流程定义分组统计 */
107
+export function getStatsByDefinition() {
108
+  return request.get<any, { data: DefinitionStatItem[] }>(`${BASE}/by-definition`)
109
+}
110
+
111
+/** 按时间范围统计 */
112
+export function getStatsByTimeRange(period: 'day' | 'week' | 'month' = 'day', days = 30) {
113
+  return request.get<any, { data: any[] }>(`${BASE}/by-time-range`, {
114
+    params: { period, days }
115
+  })
116
+}
117
+
118
+/** 瓶颈节点识别 */
119
+export function getBottleneckNodes(topN = 5) {
120
+  return request.get<any, { data: NodeDurationVO[] }>(`${BASE}/bottleneck`, {
121
+    params: { topN }
122
+  })
123
+}

+ 1
- 0
frontend/src/router/index.ts Ver fichero

@@ -16,6 +16,7 @@ const routes = [
16 16
       { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17 17
       { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18 18
       { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'bpm/statistics', name: 'bpmStats', component: () => import('@/views/bpm/ProcessStatsView.vue') },
19 20
     ]
20 21
   },
21 22
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 548
- 0
frontend/src/views/bpm/ProcessStatsView.vue Ver fichero

@@ -0,0 +1,548 @@
1
+<template>
2
+  <div class="process-stats">
3
+    <!-- 概览卡片 -->
4
+    <el-row :gutter="16">
5
+      <el-col :span="4" v-for="card in statCards" :key="card.key">
6
+        <el-card shadow="hover" class="stat-card" :class="'stat-' + card.color">
7
+          <div class="stat-icon">{{ card.icon }}</div>
8
+          <div class="stat-value">{{ card.value }}</div>
9
+          <div class="stat-label">{{ card.label }}</div>
10
+          <div class="stat-sub" v-if="card.sub">{{ card.sub }}</div>
11
+        </el-card>
12
+      </el-col>
13
+    </el-row>
14
+
15
+    <!-- 图表行1: 平均处理时长趋势 + 流程完成率饼图 -->
16
+    <el-row :gutter="16" style="margin-top: 16px">
17
+      <el-col :span="14">
18
+        <el-card shadow="never">
19
+          <template #header>
20
+            <div class="card-header">
21
+              <span>📈 平均处理时长趋势</span>
22
+              <el-radio-group v-model="trendDays" size="small" @change="loadTrendData">
23
+                <el-radio-button :value="7">7天</el-radio-button>
24
+                <el-radio-button :value="14">14天</el-radio-button>
25
+                <el-radio-button :value="30">30天</el-radio-button>
26
+                <el-radio-button :value="90">90天</el-radio-button>
27
+              </el-radio-group>
28
+            </div>
29
+          </template>
30
+          <div ref="trendChartRef" style="height: 320px"></div>
31
+        </el-card>
32
+      </el-col>
33
+      <el-col :span="10">
34
+        <el-card shadow="never">
35
+          <template #header>
36
+            <span>🎯 流程完成率</span>
37
+          </template>
38
+          <div ref="completionChartRef" style="height: 320px"></div>
39
+        </el-card>
40
+      </el-col>
41
+    </el-row>
42
+
43
+    <!-- 图表行2: 节点耗时分布柱状图 -->
44
+    <el-row :gutter="16" style="margin-top: 16px">
45
+      <el-col :span="24">
46
+        <el-card shadow="never">
47
+          <template #header>
48
+            <div class="card-header">
49
+              <span>📊 节点耗时分布</span>
50
+              <el-tag type="danger" size="small">🔴 瓶颈节点(耗时显著高于平均)</el-tag>
51
+            </div>
52
+          </template>
53
+          <div ref="nodeChartRef" style="height: 360px"></div>
54
+        </el-card>
55
+      </el-col>
56
+    </el-row>
57
+
58
+    <!-- 超时流程列表 -->
59
+    <el-row :gutter="16" style="margin-top: 16px">
60
+      <el-col :span="24">
61
+        <el-card shadow="never">
62
+          <template #header>
63
+            <div class="card-header">
64
+              <span>⏰ 超时流程列表(>48小时)</span>
65
+              <el-tag type="danger" size="small" v-if="timeoutList.length">
66
+                {{ timeoutList.length }} 个超时
67
+              </el-tag>
68
+              <el-tag type="success" size="small" v-else>无超时</el-tag>
69
+            </div>
70
+          </template>
71
+          <el-table
72
+            :data="timeoutList"
73
+            :row-class-name="timeoutRowClass"
74
+            style="width: 100%"
75
+            empty-text="暂无超时流程 ✅"
76
+            @row-click="goToDetail"
77
+          >
78
+            <el-table-column prop="processName" label="流程类型" width="140" />
79
+            <el-table-column prop="title" label="流程标题" min-width="180" show-overflow-tooltip />
80
+            <el-table-column prop="initiatorName" label="发起人" width="100" />
81
+            <el-table-column prop="currentNodeName" label="当前节点" width="140" />
82
+            <el-table-column prop="currentAssigneeName" label="当前处理人" width="120" />
83
+            <el-table-column prop="durationHours" label="已耗时" width="120" align="center">
84
+              <template #default="{ row }">
85
+                <el-tag :type="row.durationHours > 96 ? 'danger' : 'warning'" size="small">
86
+                  {{ row.durationHours.toFixed(1) }}h
87
+                </el-tag>
88
+              </template>
89
+            </el-table-column>
90
+            <el-table-column prop="priority" label="优先级" width="90" align="center">
91
+              <template #default="{ row }">
92
+                <el-tag :type="priorityType(row.priority)" size="small">
93
+                  {{ priorityText(row.priority) }}
94
+                </el-tag>
95
+              </template>
96
+            </el-table-column>
97
+            <el-table-column prop="startedAt" label="开始时间" width="170">
98
+              <template #default="{ row }">
99
+                {{ formatTime(row.startedAt) }}
100
+              </template>
101
+            </el-table-column>
102
+            <el-table-column label="操作" width="80" align="center">
103
+              <template #default="{ row }">
104
+                <el-button type="primary" link size="small" @click.stop="goToDetail(row)">
105
+                  详情
106
+                </el-button>
107
+              </template>
108
+            </el-table-column>
109
+          </el-table>
110
+        </el-card>
111
+      </el-col>
112
+    </el-row>
113
+
114
+    <!-- 按流程定义分组统计 -->
115
+    <el-row :gutter="16" style="margin-top: 16px">
116
+      <el-col :span="24">
117
+        <el-card shadow="never">
118
+          <template #header>
119
+            <span>📋 各流程类型统计</span>
120
+          </template>
121
+          <el-table :data="definitionStats" style="width: 100%" empty-text="暂无数据">
122
+            <el-table-column prop="processName" label="流程类型" width="160" />
123
+            <el-table-column prop="totalInstances" label="总数" width="80" align="center" />
124
+            <el-table-column prop="runningCount" label="运行中" width="80" align="center">
125
+              <template #default="{ row }">
126
+                <el-tag type="warning" size="small">{{ row.runningCount }}</el-tag>
127
+              </template>
128
+            </el-table-column>
129
+            <el-table-column prop="completedCount" label="已完成" width="80" align="center">
130
+              <template #default="{ row }">
131
+                <el-tag type="success" size="small">{{ row.completedCount }}</el-tag>
132
+              </template>
133
+            </el-table-column>
134
+            <el-table-column prop="rejectedCount" label="已驳回" width="80" align="center">
135
+              <template #default="{ row }">
136
+                <el-tag type="danger" size="small">{{ row.rejectedCount }}</el-tag>
137
+              </template>
138
+            </el-table-column>
139
+            <el-table-column prop="avgDurationHours" label="平均耗时" width="120" align="center">
140
+              <template #default="{ row }">
141
+                {{ row.avgDurationHours.toFixed(1) }}h
142
+              </template>
143
+            </el-table-column>
144
+            <el-table-column prop="maxDurationHours" label="最长耗时" width="120" align="center">
145
+              <template #default="{ row }">
146
+                <el-tag :type="row.maxDurationHours > 48 ? 'danger' : 'info'" size="small">
147
+                  {{ row.maxDurationHours.toFixed(1) }}h
148
+                </el-tag>
149
+              </template>
150
+            </el-table-column>
151
+          </el-table>
152
+        </el-card>
153
+      </el-col>
154
+    </el-row>
155
+  </div>
156
+</template>
157
+
158
+<script setup lang="ts">
159
+import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
160
+import { useRouter } from 'vue-router'
161
+import * as echarts from 'echarts'
162
+import {
163
+  getOverview,
164
+  getAvgDurationTrend,
165
+  getNodeDurationDistribution,
166
+  getTimeoutProcessList,
167
+  getCompletionRate,
168
+  getStatsByDefinition,
169
+  type ProcessStatsOverview,
170
+  type NodeDurationVO,
171
+  type TimeoutProcessVO,
172
+  type TrendDataPoint,
173
+  type CompletionRateItem,
174
+  type DefinitionStatItem,
175
+} from '@/api/bpmStatsApi'
176
+
177
+const router = useRouter()
178
+
179
+// ========== 响应式数据 ==========
180
+const loading = ref(false)
181
+const overview = ref<ProcessStatsOverview>({
182
+  totalInstances: 0, runningCount: 0, completedCount: 0,
183
+  rejectedCount: 0, terminatedCount: 0, timeoutCount: 0,
184
+  suspendedCount: 0, avgDurationHours: 0, completionRate: 0,
185
+})
186
+const trendData = ref<TrendDataPoint[]>([])
187
+const nodeDistribution = ref<NodeDurationVO[]>([])
188
+const timeoutList = ref<TimeoutProcessVO[]>([])
189
+const completionData = ref<CompletionRateItem[]>([])
190
+const definitionStats = ref<DefinitionStatItem[]>([])
191
+const trendDays = ref(30)
192
+
193
+// Chart refs
194
+const trendChartRef = ref<HTMLElement>()
195
+const completionChartRef = ref<HTMLElement>()
196
+const nodeChartRef = ref<HTMLElement>()
197
+
198
+let trendChart: echarts.ECharts | null = null
199
+let completionChart: echarts.ECharts | null = null
200
+let nodeChart: echarts.ECharts | null = null
201
+
202
+// ========== 统计卡片 ==========
203
+const statCards = computed(() => [
204
+  {
205
+    key: 'total', icon: '📋', label: '总流程数',
206
+    value: overview.value.totalInstances, color: 'blue',
207
+  },
208
+  {
209
+    key: 'running', icon: '🔄', label: '运行中',
210
+    value: overview.value.runningCount, color: 'orange',
211
+  },
212
+  {
213
+    key: 'completed', icon: '✅', label: '已完成',
214
+    value: overview.value.completedCount, color: 'green',
215
+  },
216
+  {
217
+    key: 'timeout', icon: '⏰', label: '超时数',
218
+    value: overview.value.timeoutCount, color: 'red',
219
+    sub: overview.value.timeoutCount > 0 ? '需关注' : '正常',
220
+  },
221
+  {
222
+    key: 'avg', icon: '⏱️', label: '平均时长',
223
+    value: overview.value.avgDurationHours.toFixed(1) + 'h', color: 'purple',
224
+  },
225
+  {
226
+    key: 'rate', icon: '🎯', label: '完成率',
227
+    value: overview.value.completionRate.toFixed(1) + '%', color: 'green',
228
+  },
229
+])
230
+
231
+// ========== 生命周期 ==========
232
+onMounted(() => {
233
+  loadAllData()
234
+  window.addEventListener('resize', handleResize)
235
+})
236
+
237
+onUnmounted(() => {
238
+  window.removeEventListener('resize', handleResize)
239
+  trendChart?.dispose()
240
+  completionChart?.dispose()
241
+  nodeChart?.dispose()
242
+})
243
+
244
+// ========== 数据加载 ==========
245
+async function loadAllData() {
246
+  loading.value = true
247
+  try {
248
+    const [overviewRes, trendRes, nodeRes, timeoutRes, completionRes, defRes] = await Promise.all([
249
+      getOverview(),
250
+      getAvgDurationTrend(trendDays.value),
251
+      getNodeDurationDistribution(),
252
+      getTimeoutProcessList(),
253
+      getCompletionRate(),
254
+      getStatsByDefinition(),
255
+    ])
256
+    overview.value = overviewRes.data
257
+    trendData.value = trendRes.data || []
258
+    nodeDistribution.value = nodeRes.data || []
259
+    timeoutList.value = timeoutRes.data || []
260
+    completionData.value = completionRes.data || []
261
+    definitionStats.value = defRes.data || []
262
+
263
+    await nextTick()
264
+    renderTrendChart()
265
+    renderCompletionChart()
266
+    renderNodeChart()
267
+  } catch (e) {
268
+    console.error('加载统计数据失败', e)
269
+  } finally {
270
+    loading.value = false
271
+  }
272
+}
273
+
274
+async function loadTrendData() {
275
+  try {
276
+    const res = await getAvgDurationTrend(trendDays.value)
277
+    trendData.value = res.data || []
278
+    await nextTick()
279
+    renderTrendChart()
280
+  } catch (e) {
281
+    console.error('加载趋势数据失败', e)
282
+  }
283
+}
284
+
285
+// ========== 图表渲染 ==========
286
+function renderTrendChart() {
287
+  if (!trendChartRef.value) return
288
+  trendChart = trendChart || echarts.init(trendChartRef.value)
289
+  const data = trendData.value
290
+  trendChart.setOption({
291
+    tooltip: {
292
+      trigger: 'axis',
293
+      formatter: (params: any) => {
294
+        const p = params[0]
295
+        return `${p.name}<br/>平均: ${p.value}h<br/>完成数: ${data[p.dataIndex]?.completedCount || 0}`
296
+      },
297
+    },
298
+    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
299
+    xAxis: {
300
+      type: 'category',
301
+      data: data.map(d => d.date?.slice(5) || ''),
302
+      axisLabel: { rotate: 30 },
303
+    },
304
+    yAxis: { type: 'value', name: '小时(h)', min: 0 },
305
+    series: [
306
+      {
307
+        name: '平均耗时',
308
+        type: 'line',
309
+        smooth: true,
310
+        data: data.map(d => d.avgHours),
311
+        areaStyle: { opacity: 0.1 },
312
+        itemStyle: { color: '#409eff' },
313
+        markLine: {
314
+          silent: true,
315
+          data: [{ yAxis: 48, name: '超时线(48h)', label: { formatter: '超时线 48h' } }],
316
+          lineStyle: { color: '#f56c6c', type: 'dashed' },
317
+        },
318
+      },
319
+      {
320
+        name: '最长耗时',
321
+        type: 'line',
322
+        data: data.map(d => d.maxHours),
323
+        lineStyle: { type: 'dotted', opacity: 0.6 },
324
+        itemStyle: { color: '#e6a23c' },
325
+        symbol: 'none',
326
+      },
327
+    ],
328
+  })
329
+}
330
+
331
+function renderCompletionChart() {
332
+  if (!completionChartRef.value) return
333
+  completionChart = completionChart || echarts.init(completionChartRef.value)
334
+  const data = completionData.value
335
+
336
+  if (data.length === 0) {
337
+    completionChart.setOption({
338
+      title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
339
+    })
340
+    return
341
+  }
342
+
343
+  completionChart.setOption({
344
+    tooltip: {
345
+      trigger: 'item',
346
+      formatter: (p: any) => `${p.name}<br/>完成率: ${p.value}%<br/>${p.data.total}/${p.data.totalAll}`,
347
+    },
348
+    legend: {
349
+      type: 'scroll',
350
+      bottom: 0,
351
+      data: data.map(d => d.processName),
352
+    },
353
+    series: [
354
+      {
355
+        type: 'pie',
356
+        radius: ['35%', '60%'],
357
+        center: ['50%', '45%'],
358
+        avoidLabelOverlap: true,
359
+        label: {
360
+          show: true,
361
+          formatter: '{b}\n{c}%',
362
+        },
363
+        emphasis: {
364
+          label: { show: true, fontSize: 14, fontWeight: 'bold' },
365
+        },
366
+        data: data.map(d => ({
367
+          name: d.processName,
368
+          value: d.completionRate,
369
+          total: d.completed,
370
+          totalAll: d.total,
371
+        })),
372
+      },
373
+    ],
374
+  })
375
+}
376
+
377
+function renderNodeChart() {
378
+  if (!nodeChartRef.value) return
379
+  nodeChart = nodeChart || echarts.init(nodeChartRef.value)
380
+  const data = nodeDistribution.value
381
+
382
+  if (data.length === 0) {
383
+    nodeChart.setOption({
384
+      title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
385
+    })
386
+    return
387
+  }
388
+
389
+  const colors = data.map(d => d.isBottleneck ? '#f56c6c' : '#409eff')
390
+
391
+  nodeChart.setOption({
392
+    tooltip: {
393
+      trigger: 'axis',
394
+      axisPointer: { type: 'shadow' },
395
+      formatter: (params: any) => {
396
+        const idx = params[0].dataIndex
397
+        const node = data[idx]
398
+        return `${node.nodeName}${node.isBottleneck ? ' 🔴瓶颈' : ''}<br/>` +
399
+          `平均: ${node.avgDurationHours}h<br/>` +
400
+          `最长: ${node.maxDurationHours}h<br/>` +
401
+          `最短: ${node.minDurationHours}h<br/>` +
402
+          `审批次数: ${node.approvalCount}`
403
+      },
404
+    },
405
+    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
406
+    xAxis: {
407
+      type: 'category',
408
+      data: data.map(d => d.nodeName),
409
+      axisLabel: { rotate: 15, interval: 0 },
410
+    },
411
+    yAxis: { type: 'value', name: '小时(h)', min: 0 },
412
+    series: [
413
+      {
414
+        name: '平均耗时',
415
+        type: 'bar',
416
+        barMaxWidth: 50,
417
+        data: data.map((d, i) => ({
418
+          value: d.avgDurationHours,
419
+          itemStyle: {
420
+            color: colors[i],
421
+            borderRadius: [4, 4, 0, 0],
422
+          },
423
+        })),
424
+        label: {
425
+          show: true,
426
+          position: 'top',
427
+          formatter: (p: any) => {
428
+            const node = data[p.dataIndex]
429
+            return node.isBottleneck ? `${p.value}h ⚠️` : `${p.value}h`
430
+          },
431
+          color: '#333',
432
+          fontSize: 11,
433
+        },
434
+        markLine: {
435
+          silent: true,
436
+          data: [{ type: 'average', name: '平均值' }],
437
+          lineStyle: { color: '#67c23a', type: 'dashed' },
438
+          label: { formatter: '平均 {c}h' },
439
+        },
440
+      },
441
+      {
442
+        name: '最长耗时',
443
+        type: 'bar',
444
+        barMaxWidth: 30,
445
+        data: data.map(d => ({
446
+          value: d.maxDurationHours,
447
+          itemStyle: { color: '#e6a23c', opacity: 0.6, borderRadius: [4, 4, 0, 0] },
448
+        })),
449
+      },
450
+    ],
451
+  })
452
+}
453
+
454
+// ========== 工具函数 ==========
455
+function timeoutRowClass({ row }: { row: TimeoutProcessVO }) {
456
+  if (row.durationHours > 96) return 'timeout-critical'
457
+  if (row.durationHours > 48) return 'timeout-warning'
458
+  return ''
459
+}
460
+
461
+function priorityType(priority: number) {
462
+  switch (priority) {
463
+    case 2: return 'danger'
464
+    case 1: return 'warning'
465
+    default: return 'info'
466
+  }
467
+}
468
+
469
+function priorityText(priority: number) {
470
+  switch (priority) {
471
+    case 2: return '特急'
472
+    case 1: return '紧急'
473
+    default: return '普通'
474
+  }
475
+}
476
+
477
+function formatTime(t: string) {
478
+  if (!t) return '-'
479
+  return t.replace('T', ' ').slice(0, 16)
480
+}
481
+
482
+function goToDetail(row: TimeoutProcessVO) {
483
+  router.push(`/bpm/instance/${row.instanceId}`)
484
+}
485
+
486
+function handleResize() {
487
+  trendChart?.resize()
488
+  completionChart?.resize()
489
+  nodeChart?.resize()
490
+}
491
+</script>
492
+
493
+<style scoped>
494
+.process-stats {
495
+  padding: 0;
496
+}
497
+
498
+.stat-card {
499
+  text-align: center;
500
+  padding: 8px 0;
501
+}
502
+
503
+.stat-icon {
504
+  font-size: 28px;
505
+}
506
+
507
+.stat-value {
508
+  font-size: 32px;
509
+  font-weight: 700;
510
+  margin: 4px 0;
511
+}
512
+
513
+.stat-label {
514
+  font-size: 13px;
515
+  color: #666;
516
+}
517
+
518
+.stat-sub {
519
+  font-size: 12px;
520
+  color: #999;
521
+  margin-top: 2px;
522
+}
523
+
524
+.stat-blue .stat-value { color: #409eff; }
525
+.stat-orange .stat-value { color: #e6a23c; }
526
+.stat-green .stat-value { color: #67c23a; }
527
+.stat-red .stat-value { color: #f56c6c; }
528
+.stat-purple .stat-value { color: #9b59b6; }
529
+
530
+.card-header {
531
+  display: flex;
532
+  justify-content: space-between;
533
+  align-items: center;
534
+}
535
+
536
+:deep(.timeout-critical) {
537
+  background-color: #fef0f0 !important;
538
+}
539
+
540
+:deep(.timeout-warning) {
541
+  background-color: #fdf6ec !important;
542
+}
543
+
544
+:deep(.timeout-critical:hover > td),
545
+:deep(.timeout-warning:hover > td) {
546
+  background-color: transparent !important;
547
+}
548
+</style>

+ 78
- 0
wm-bpm/src/main/java/com/water/bpm/controller/ProcessStatisticsController.java Ver fichero

@@ -0,0 +1,78 @@
1
+package com.water.bpm.controller;
2
+
3
+import com.water.bpm.entity.dto.NodeDurationVO;
4
+import com.water.bpm.entity.dto.ProcessStatsOverview;
5
+import com.water.bpm.entity.dto.TimeoutProcessVO;
6
+import com.water.bpm.service.ProcessStatisticsService;
7
+import com.water.common.core.result.R;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 流程统计评估 Controller
18
+ */
19
+@Tag(name = "流程统计评估")
20
+@RestController
21
+@RequestMapping("/bpm/statistics")
22
+@RequiredArgsConstructor
23
+public class ProcessStatisticsController {
24
+
25
+    private final ProcessStatisticsService statisticsService;
26
+
27
+    @Operation(summary = "统计概览")
28
+    @GetMapping("/overview")
29
+    public R<ProcessStatsOverview> overview() {
30
+        return R.ok(statisticsService.getOverview());
31
+    }
32
+
33
+    @Operation(summary = "平均处理时长趋势")
34
+    @GetMapping("/avg-duration-trend")
35
+    public R<List<Map<String, Object>>> avgDurationTrend(
36
+            @RequestParam(defaultValue = "30") int days) {
37
+        return R.ok(statisticsService.getAvgDurationTrend(days));
38
+    }
39
+
40
+    @Operation(summary = "节点耗时分布")
41
+    @GetMapping("/node-duration")
42
+    public R<List<NodeDurationVO>> nodeDurationDistribution() {
43
+        return R.ok(statisticsService.getNodeDurationDistribution());
44
+    }
45
+
46
+    @Operation(summary = "超时流程列表")
47
+    @GetMapping("/timeout")
48
+    public R<List<TimeoutProcessVO>> timeoutProcessList() {
49
+        return R.ok(statisticsService.getTimeoutProcessList());
50
+    }
51
+
52
+    @Operation(summary = "流程完成率")
53
+    @GetMapping("/completion-rate")
54
+    public R<List<Map<String, Object>>> completionRate() {
55
+        return R.ok(statisticsService.getCompletionRate());
56
+    }
57
+
58
+    @Operation(summary = "按流程定义分组统计")
59
+    @GetMapping("/by-definition")
60
+    public R<List<Map<String, Object>>> statsByDefinition() {
61
+        return R.ok(statisticsService.getStatsByDefinition());
62
+    }
63
+
64
+    @Operation(summary = "按时间范围统计")
65
+    @GetMapping("/by-time-range")
66
+    public R<List<Map<String, Object>>> statsByTimeRange(
67
+            @RequestParam(defaultValue = "day") String period,
68
+            @RequestParam(defaultValue = "30") int days) {
69
+        return R.ok(statisticsService.getStatsByTimeRange(period, days));
70
+    }
71
+
72
+    @Operation(summary = "瓶颈节点识别")
73
+    @GetMapping("/bottleneck")
74
+    public R<List<NodeDurationVO>> bottleneckNodes(
75
+            @RequestParam(defaultValue = "5") int topN) {
76
+        return R.ok(statisticsService.getBottleneckNodes(topN));
77
+    }
78
+}

+ 24
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/NodeDurationVO.java Ver fichero

@@ -0,0 +1,24 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 节点耗时分布 VO
7
+ */
8
+@Data
9
+public class NodeDurationVO {
10
+    /** 节点标识 */
11
+    private String nodeId;
12
+    /** 节点名称 */
13
+    private String nodeName;
14
+    /** 平均耗时(小时) */
15
+    private Double avgDurationHours;
16
+    /** 最大耗时(小时) */
17
+    private Double maxDurationHours;
18
+    /** 最小耗时(小时) */
19
+    private Double minDurationHours;
20
+    /** 审批次数 */
21
+    private Integer approvalCount;
22
+    /** 是否瓶颈节点 */
23
+    private Boolean isBottleneck;
24
+}

+ 31
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessStatsOverview.java Ver fichero

@@ -0,0 +1,31 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 流程统计概览 DTO
10
+ */
11
+@Data
12
+public class ProcessStatsOverview {
13
+    /** 总流程数 */
14
+    private Integer totalInstances;
15
+    /** 运行中 */
16
+    private Integer runningCount;
17
+    /** 已完成 */
18
+    private Integer completedCount;
19
+    /** 已驳回 */
20
+    private Integer rejectedCount;
21
+    /** 已撤回 */
22
+    private Integer terminatedCount;
23
+    /** 超时数 */
24
+    private Integer timeoutCount;
25
+    /** 平均处理时长(小时) */
26
+    private Double avgDurationHours;
27
+    /** 流程完成率(%) */
28
+    private Double completionRate;
29
+    /** 挂起数 */
30
+    private Integer suspendedCount;
31
+}

+ 34
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/TimeoutProcessVO.java Ver fichero

@@ -0,0 +1,34 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 超时流程 VO
9
+ */
10
+@Data
11
+public class TimeoutProcessVO {
12
+    /** 流程实例ID */
13
+    private Long id;
14
+    /** 实例UUID */
15
+    private String instanceId;
16
+    /** 流程名称 */
17
+    private String processName;
18
+    /** 流程标题 */
19
+    private String title;
20
+    /** 发起人姓名 */
21
+    private String initiatorName;
22
+    /** 当前节点名称 */
23
+    private String currentNodeName;
24
+    /** 当前处理人姓名 */
25
+    private String currentAssigneeName;
26
+    /** 开始时间 */
27
+    private LocalDateTime startedAt;
28
+    /** 已耗时(小时) */
29
+    private Double durationHours;
30
+    /** 状态 */
31
+    private String status;
32
+    /** 优先级 */
33
+    private Integer priority;
34
+}

+ 145
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/ProcessStatisticsMapper.java Ver fichero

@@ -0,0 +1,145 @@
1
+package com.water.bpm.mapper;
2
+
3
+import org.apache.ibatis.annotations.Mapper;
4
+import org.apache.ibatis.annotations.Param;
5
+import org.apache.ibatis.annotations.Select;
6
+
7
+import java.time.LocalDateTime;
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+/**
12
+ * 流程统计 Mapper — 聚合查询
13
+ */
14
+@Mapper
15
+public interface ProcessStatisticsMapper {
16
+
17
+    /**
18
+     * 统计各状态流程数量
19
+     */
20
+    @Select("SELECT status, COUNT(*) as count FROM bpm_process_instance WHERE deleted = 0 GROUP BY status")
21
+    List<Map<String, Object>> countByStatus();
22
+
23
+    /**
24
+     * 统计总流程数和平均耗时
25
+     */
26
+    @Select("SELECT COUNT(*) as total, " +
27
+            "AVG(duration_seconds) as avg_duration_seconds, " +
28
+            "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count " +
29
+            "FROM bpm_process_instance WHERE deleted = 0")
30
+    Map<String, Object> overallStats();
31
+
32
+    /**
33
+     * 按流程定义分组统计
34
+     */
35
+    @Select("SELECT d.id as definition_id, d.process_name, d.process_key, " +
36
+            "COUNT(i.id) as total_instances, " +
37
+            "SUM(CASE WHEN i.status = 'running' THEN 1 ELSE 0 END) as running_count, " +
38
+            "SUM(CASE WHEN i.status = 'completed' THEN 1 ELSE 0 END) as completed_count, " +
39
+            "SUM(CASE WHEN i.status = 'rejected' THEN 1 ELSE 0 END) as rejected_count, " +
40
+            "SUM(CASE WHEN i.status = 'terminated' THEN 1 ELSE 0 END) as terminated_count, " +
41
+            "AVG(i.duration_seconds) as avg_duration_seconds, " +
42
+            "MAX(i.duration_seconds) as max_duration_seconds, " +
43
+            "MIN(i.duration_seconds) as min_duration_seconds " +
44
+            "FROM bpm_process_definition d " +
45
+            "LEFT JOIN bpm_process_instance i ON d.id = i.definition_id AND i.deleted = 0 " +
46
+            "WHERE d.deleted = 0 " +
47
+            "GROUP BY d.id, d.process_name, d.process_key " +
48
+            "ORDER BY total_instances DESC")
49
+    List<Map<String, Object>> statsByDefinition();
50
+
51
+    /**
52
+     * 节点耗时分布 — 从审批记录聚合
53
+     */
54
+    @Select("SELECT node_id, node_name, " +
55
+            "AVG(EXTRACT(EPOCH FROM (approved_at - created_at))) as avg_duration_seconds, " +
56
+            "MAX(EXTRACT(EPOCH FROM (approved_at - created_at))) as max_duration_seconds, " +
57
+            "MIN(EXTRACT(EPOCH FROM (approved_at - created_at))) as min_duration_seconds, " +
58
+            "COUNT(*) as approval_count " +
59
+            "FROM bpm_approval_record WHERE deleted = 0 " +
60
+            "GROUP BY node_id, node_name " +
61
+            "ORDER BY avg_duration_seconds DESC")
62
+    List<Map<String, Object>> nodeDurationDistribution();
63
+
64
+    /**
65
+     * 超时流程列表(运行中且单节点审批超过48小时)
66
+     */
67
+    @Select("SELECT DISTINCT i.id, i.instance_id, i.process_name, i.title, " +
68
+            "i.initiator_name, i.current_node_name, i.current_assignee_name, " +
69
+            "i.started_at, i.status, i.priority, " +
70
+            "EXTRACT(EPOCH FROM (NOW() - i.started_at)) / 3600 as duration_hours " +
71
+            "FROM bpm_process_instance i " +
72
+            "WHERE i.deleted = 0 AND i.status = 'running' " +
73
+            "AND EXISTS ( " +
74
+            "  SELECT 1 FROM bpm_approval_record r " +
75
+            "  WHERE r.instance_id = i.id AND r.deleted = 0 " +
76
+            "  AND EXTRACT(EPOCH FROM (COALESCE(r.approved_at, NOW()) - r.created_at)) > 172800 " +
77
+            ") " +
78
+            "ORDER BY duration_hours DESC")
79
+    List<Map<String, Object>> timeoutProcessList();
80
+
81
+    /**
82
+     * 超时流程列表(简化版:运行中且启动超过48小时)
83
+     */
84
+    @Select("SELECT id, instance_id, process_name, title, " +
85
+            "initiator_name, current_node_name, current_assignee_name, " +
86
+            "started_at, status, priority, " +
87
+            "EXTRACT(EPOCH FROM (NOW() - started_at)) / 3600 as duration_hours " +
88
+            "FROM bpm_process_instance " +
89
+            "WHERE deleted = 0 AND status = 'running' " +
90
+            "AND EXTRACT(EPOCH FROM (NOW() - started_at)) > 172800 " +
91
+            "ORDER BY duration_hours DESC")
92
+    List<Map<String, Object>> timeoutProcessListSimple();
93
+
94
+    /**
95
+     * 平均处理时长趋势(按日聚合)
96
+     */
97
+    @Select("SELECT DATE(completed_at) as stat_date, " +
98
+            "COUNT(*) as completed_count, " +
99
+            "AVG(duration_seconds) as avg_duration_seconds, " +
100
+            "MAX(duration_seconds) as max_duration_seconds, " +
101
+            "MIN(duration_seconds) as min_duration_seconds " +
102
+            "FROM bpm_process_instance " +
103
+            "WHERE deleted = 0 AND status = 'completed' " +
104
+            "AND completed_at >= #{startDate} AND completed_at <= #{endDate} " +
105
+            "GROUP BY DATE(completed_at) " +
106
+            "ORDER BY stat_date")
107
+    List<Map<String, Object>> avgDurationTrend(@Param("startDate") LocalDateTime startDate,
108
+                                                 @Param("endDate") LocalDateTime endDate);
109
+
110
+    /**
111
+     * 按时间范围统计(按周/月聚合)
112
+     */
113
+    @Select("SELECT DATE_TRUNC(#{period}, completed_at) as stat_period, " +
114
+            "COUNT(*) as completed_count, " +
115
+            "AVG(duration_seconds) as avg_duration_seconds " +
116
+            "FROM bpm_process_instance " +
117
+            "WHERE deleted = 0 AND status = 'completed' " +
118
+            "AND completed_at >= #{startDate} AND completed_at <= #{endDate} " +
119
+            "GROUP BY DATE_TRUNC(#{period}, completed_at) " +
120
+            "ORDER BY stat_period")
121
+    List<Map<String, Object>> statsByTimeRange(@Param("period") String period,
122
+                                                @Param("startDate") LocalDateTime startDate,
123
+                                                @Param("endDate") LocalDateTime endDate);
124
+
125
+    /**
126
+     * 流程完成率(按流程定义)
127
+     */
128
+    @Select("SELECT process_key, process_name, " +
129
+            "COUNT(*) as total, " +
130
+            "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, " +
131
+            "ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2) as completion_rate " +
132
+            "FROM bpm_process_instance WHERE deleted = 0 " +
133
+            "GROUP BY process_key, process_name " +
134
+            "ORDER BY completion_rate DESC")
135
+    List<Map<String, Object>> completionRateByDefinition();
136
+
137
+    /**
138
+     * 超时统计数量
139
+     */
140
+    @Select("SELECT COUNT(DISTINCT i.id) as timeout_count " +
141
+            "FROM bpm_process_instance i " +
142
+            "WHERE i.deleted = 0 AND i.status = 'running' " +
143
+            "AND EXTRACT(EPOCH FROM (NOW() - i.started_at)) > 172800")
144
+    Map<String, Object> timeoutCount();
145
+}

+ 273
- 0
wm-bpm/src/main/java/com/water/bpm/service/ProcessStatisticsService.java Ver fichero

@@ -0,0 +1,273 @@
1
+package com.water.bpm.service;
2
+
3
+import com.water.bpm.entity.dto.NodeDurationVO;
4
+import com.water.bpm.entity.dto.ProcessStatsOverview;
5
+import com.water.bpm.entity.dto.TimeoutProcessVO;
6
+import com.water.bpm.mapper.ProcessStatisticsMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.time.LocalDateTime;
12
+import java.util.*;
13
+import java.util.stream.Collectors;
14
+
15
+/**
16
+ * 流程统计评估服务
17
+ * 提供:平均处理时长/各节点耗时分布/超时流程列表/流程完成率/瓶颈节点识别/按时间范围统计/按流程定义分组
18
+ */
19
+@Slf4j
20
+@Service
21
+@RequiredArgsConstructor
22
+public class ProcessStatisticsService {
23
+
24
+    private final ProcessStatisticsMapper statisticsMapper;
25
+
26
+    /** 超时阈值:48小时(秒) */
27
+    private static final long TIMEOUT_THRESHOLD_SECONDS = 48 * 3600L;
28
+
29
+    /**
30
+     * 统计概览 — 仪表盘卡片数据
31
+     */
32
+    public ProcessStatsOverview getOverview() {
33
+        ProcessStatsOverview overview = new ProcessStatsOverview();
34
+
35
+        // 各状态数量
36
+        Map<String, Integer> statusCounts = new HashMap<>();
37
+        List<Map<String, Object>> statusList = statisticsMapper.countByStatus();
38
+        for (Map<String, Object> row : statusList) {
39
+            String status = String.valueOf(row.get("status"));
40
+            int count = ((Number) row.get("count")).intValue();
41
+            statusCounts.put(status, count);
42
+        }
43
+
44
+        // 总体统计
45
+        Map<String, Object> overall = statisticsMapper.overallStats();
46
+        int total = overall != null && overall.get("total") != null
47
+                ? ((Number) overall.get("total")).intValue() : 0;
48
+        double avgSeconds = overall != null && overall.get("avg_duration_seconds") != null
49
+                ? ((Number) overall.get("avg_duration_seconds")).doubleValue() : 0.0;
50
+        int completedTotal = overall != null && overall.get("completed_count") != null
51
+                ? ((Number) overall.get("completed_count")).intValue() : 0;
52
+
53
+        overview.setTotalInstances(total);
54
+        overview.setRunningCount(statusCounts.getOrDefault("running", 0));
55
+        overview.setCompletedCount(statusCounts.getOrDefault("completed", 0));
56
+        overview.setRejectedCount(statusCounts.getOrDefault("rejected", 0));
57
+        overview.setTerminatedCount(statusCounts.getOrDefault("terminated", 0));
58
+        overview.setSuspendedCount(statusCounts.getOrDefault("suspended", 0));
59
+        overview.setAvgDurationHours(Math.round(avgSeconds / 3600.0 * 100.0) / 100.0);
60
+
61
+        // 完成率
62
+        double completionRate = total > 0 ? Math.round(100.0 * completedTotal / total * 100.0) / 100.0 : 0.0;
63
+        overview.setCompletionRate(completionRate);
64
+
65
+        // 超时数量
66
+        Map<String, Object> timeoutResult = statisticsMapper.timeoutCount();
67
+        int timeoutCount = timeoutResult != null && timeoutResult.get("timeout_count") != null
68
+                ? ((Number) timeoutResult.get("timeout_count")).intValue() : 0;
69
+        overview.setTimeoutCount(timeoutCount);
70
+
71
+        return overview;
72
+    }
73
+
74
+    /**
75
+     * 平均处理时长趋势(按日)
76
+     * @param days 最近天数
77
+     */
78
+    public List<Map<String, Object>> getAvgDurationTrend(int days) {
79
+        LocalDateTime endDate = LocalDateTime.now();
80
+        LocalDateTime startDate = endDate.minusDays(days);
81
+        List<Map<String, Object>> trend = statisticsMapper.avgDurationTrend(startDate, endDate);
82
+
83
+        // 转换为前端友好格式
84
+        return trend.stream().map(row -> {
85
+            Map<String, Object> item = new LinkedHashMap<>();
86
+            item.put("date", String.valueOf(row.get("stat_date")));
87
+            item.put("completedCount", ((Number) row.getOrDefault("completed_count", 0)).intValue());
88
+            double avgSeconds = row.get("avg_duration_seconds") != null
89
+                    ? ((Number) row.get("avg_duration_seconds")).doubleValue() : 0.0;
90
+            item.put("avgHours", Math.round(avgSeconds / 3600.0 * 100.0) / 100.0);
91
+            double maxSeconds = row.get("max_duration_seconds") != null
92
+                    ? ((Number) row.get("max_duration_seconds")).doubleValue() : 0.0;
93
+            item.put("maxHours", Math.round(maxSeconds / 3600.0 * 100.0) / 100.0);
94
+            double minSeconds = row.get("min_duration_seconds") != null
95
+                    ? ((Number) row.get("min_duration_seconds")).doubleValue() : 0.0;
96
+            item.put("minHours", Math.round(minSeconds / 3600.0 * 100.0) / 100.0);
97
+            return item;
98
+        }).collect(Collectors.toList());
99
+    }
100
+
101
+    /**
102
+     * 节点耗时分布(识别瓶颈节点)
103
+     */
104
+    public List<NodeDurationVO> getNodeDurationDistribution() {
105
+        List<Map<String, Object>> raw = statisticsMapper.nodeDurationDistribution();
106
+
107
+        List<NodeDurationVO> result = new ArrayList<>();
108
+        double maxAvg = 0;
109
+        String bottleneckNodeId = null;
110
+
111
+        for (Map<String, Object> row : raw) {
112
+            NodeDurationVO vo = new NodeDurationVO();
113
+            vo.setNodeId(String.valueOf(row.get("node_id")));
114
+            vo.setNodeName(String.valueOf(row.get("node_name")));
115
+
116
+            double avgSec = row.get("avg_duration_seconds") != null
117
+                    ? ((Number) row.get("avg_duration_seconds")).doubleValue() : 0.0;
118
+            double maxSec = row.get("max_duration_seconds") != null
119
+                    ? ((Number) row.get("max_duration_seconds")).doubleValue() : 0.0;
120
+            double minSec = row.get("min_duration_seconds") != null
121
+                    ? ((Number) row.get("min_duration_seconds")).doubleValue() : 0.0;
122
+            int count = row.get("approval_count") != null
123
+                    ? ((Number) row.get("approval_count")).intValue() : 0;
124
+
125
+            vo.setAvgDurationHours(Math.round(avgSec / 3600.0 * 100.0) / 100.0);
126
+            vo.setMaxDurationHours(Math.round(maxSec / 3600.0 * 100.0) / 100.0);
127
+            vo.setMinDurationHours(Math.round(minSec / 3600.0 * 100.0) / 100.0);
128
+            vo.setApprovalCount(count);
129
+            vo.setIsBottleneck(false);
130
+
131
+            if (avgSec > maxAvg) {
132
+                maxAvg = avgSec;
133
+                bottleneckNodeId = vo.getNodeId();
134
+            }
135
+
136
+            result.add(vo);
137
+        }
138
+
139
+        // 标记瓶颈节点(平均耗时最长的 + 超过平均2倍的)
140
+        if (result.size() > 1) {
141
+            double totalAvg = result.stream().mapToDouble(NodeDurationVO::getAvgDurationHours).average().orElse(0);
142
+            for (NodeDurationVO vo : result) {
143
+                if (vo.getNodeId().equals(bottleneckNodeId)
144
+                        || vo.getAvgDurationHours() > totalAvg * 2) {
145
+                    vo.setIsBottleneck(true);
146
+                }
147
+            }
148
+        }
149
+
150
+        return result;
151
+    }
152
+
153
+    /**
154
+     * 超时流程列表
155
+     * 超时判定:单节点审批超过48小时 或 流程启动后运行超过48小时仍为 running
156
+     */
157
+    public List<TimeoutProcessVO> getTimeoutProcessList() {
158
+        // 先尝试精确查询(基于审批记录),若无结果则用简化查询
159
+        List<Map<String, Object>> raw = statisticsMapper.timeoutProcessList();
160
+        if (raw == null || raw.isEmpty()) {
161
+            raw = statisticsMapper.timeoutProcessListSimple();
162
+        }
163
+
164
+        return raw.stream().map(row -> {
165
+            TimeoutProcessVO vo = new TimeoutProcessVO();
166
+            vo.setId(row.get("id") != null ? ((Number) row.get("id")).longValue() : null);
167
+            vo.setInstanceId(String.valueOf(row.get("instance_id")));
168
+            vo.setProcessName(String.valueOf(row.get("process_name")));
169
+            vo.setTitle(String.valueOf(row.get("title")));
170
+            vo.setInitiatorName(String.valueOf(row.get("initiator_name")));
171
+            vo.setCurrentNodeName(String.valueOf(row.get("current_node_name")));
172
+            vo.setCurrentAssigneeName(String.valueOf(row.get("current_assignee_name")));
173
+            vo.setStatus(String.valueOf(row.get("status")));
174
+            vo.setPriority(row.get("priority") != null ? ((Number) row.get("priority")).intValue() : 0);
175
+            double hours = row.get("duration_hours") != null
176
+                    ? ((Number) row.get("duration_hours")).doubleValue() : 0.0;
177
+            vo.setDurationHours(Math.round(hours * 100.0) / 100.0);
178
+            // started_at may be a Timestamp
179
+            Object startedAtObj = row.get("started_at");
180
+            if (startedAtObj instanceof java.sql.Timestamp ts) {
181
+                vo.setStartedAt(ts.toLocalDateTime());
182
+            } else if (startedAtObj instanceof LocalDateTime ldt) {
183
+                vo.setStartedAt(ldt);
184
+            }
185
+            return vo;
186
+        }).collect(Collectors.toList());
187
+    }
188
+
189
+    /**
190
+     * 流程完成率(按流程定义分组)
191
+     */
192
+    public List<Map<String, Object>> getCompletionRate() {
193
+        List<Map<String, Object>> raw = statisticsMapper.completionRateByDefinition();
194
+        return raw.stream().map(row -> {
195
+            Map<String, Object> item = new LinkedHashMap<>();
196
+            item.put("processKey", String.valueOf(row.get("process_key")));
197
+            item.put("processName", String.valueOf(row.get("process_name")));
198
+            item.put("total", ((Number) row.getOrDefault("total", 0)).intValue());
199
+            item.put("completed", ((Number) row.getOrDefault("completed", 0)).intValue());
200
+            double rate = row.get("completion_rate") != null
201
+                    ? ((Number) row.get("completion_rate")).doubleValue() : 0.0;
202
+            item.put("completionRate", rate);
203
+            return item;
204
+        }).collect(Collectors.toList());
205
+    }
206
+
207
+    /**
208
+     * 按流程定义分组统计
209
+     */
210
+    public List<Map<String, Object>> getStatsByDefinition() {
211
+        List<Map<String, Object>> raw = statisticsMapper.statsByDefinition();
212
+        return raw.stream().map(row -> {
213
+            Map<String, Object> item = new LinkedHashMap<>();
214
+            item.put("definitionId", row.get("definition_id"));
215
+            item.put("processName", String.valueOf(row.get("process_name")));
216
+            item.put("processKey", String.valueOf(row.get("process_key")));
217
+            item.put("totalInstances", ((Number) row.getOrDefault("total_instances", 0)).intValue());
218
+            item.put("runningCount", ((Number) row.getOrDefault("running_count", 0)).intValue());
219
+            item.put("completedCount", ((Number) row.getOrDefault("completed_count", 0)).intValue());
220
+            item.put("rejectedCount", ((Number) row.getOrDefault("rejected_count", 0)).intValue());
221
+            item.put("terminatedCount", ((Number) row.getOrDefault("terminated_count", 0)).intValue());
222
+            double avgSec = row.get("avg_duration_seconds") != null
223
+                    ? ((Number) row.get("avg_duration_seconds")).doubleValue() : 0.0;
224
+            item.put("avgDurationHours", Math.round(avgSec / 3600.0 * 100.0) / 100.0);
225
+            double maxSec = row.get("max_duration_seconds") != null
226
+                    ? ((Number) row.get("max_duration_seconds")).doubleValue() : 0.0;
227
+            item.put("maxDurationHours", Math.round(maxSec / 3600.0 * 100.0) / 100.0);
228
+            double minSec = row.get("min_duration_seconds") != null
229
+                    ? ((Number) row.get("min_duration_seconds")).doubleValue() : 0.0;
230
+            item.put("minDurationHours", Math.round(minSec / 3600.0 * 100.0) / 100.0);
231
+            return item;
232
+        }).collect(Collectors.toList());
233
+    }
234
+
235
+    /**
236
+     * 按时间范围统计
237
+     * @param period day/week/month
238
+     * @param days 最近天数
239
+     */
240
+    public List<Map<String, Object>> getStatsByTimeRange(String period, int days) {
241
+        LocalDateTime endDate = LocalDateTime.now();
242
+        LocalDateTime startDate = endDate.minusDays(days);
243
+
244
+        // PostgreSQL DATE_TRUNC 参数: day, week, month
245
+        String pgPeriod = switch (period) {
246
+            case "week" -> "week";
247
+            case "month" -> "month";
248
+            default -> "day";
249
+        };
250
+
251
+        List<Map<String, Object>> raw = statisticsMapper.statsByTimeRange(pgPeriod, startDate, endDate);
252
+        return raw.stream().map(row -> {
253
+            Map<String, Object> item = new LinkedHashMap<>();
254
+            item.put("period", String.valueOf(row.get("stat_period")));
255
+            item.put("completedCount", ((Number) row.getOrDefault("completed_count", 0)).intValue());
256
+            double avgSec = row.get("avg_duration_seconds") != null
257
+                    ? ((Number) row.get("avg_duration_seconds")).doubleValue() : 0.0;
258
+            item.put("avgHours", Math.round(avgSec / 3600.0 * 100.0) / 100.0);
259
+            return item;
260
+        }).collect(Collectors.toList());
261
+    }
262
+
263
+    /**
264
+     * 识别瓶颈节点 — 返回耗时最长的前N个节点
265
+     */
266
+    public List<NodeDurationVO> getBottleneckNodes(int topN) {
267
+        List<NodeDurationVO> all = getNodeDurationDistribution();
268
+        return all.stream()
269
+                .filter(NodeDurationVO::getIsBottleneck)
270
+                .limit(topN)
271
+                .collect(Collectors.toList());
272
+    }
273
+}

+ 328
- 0
wm-bpm/src/test/java/com/water/bpm/service/ProcessStatisticsServiceTest.java Ver fichero

@@ -0,0 +1,328 @@
1
+package com.water.bpm.service;
2
+
3
+import com.water.bpm.entity.dto.NodeDurationVO;
4
+import com.water.bpm.entity.dto.ProcessStatsOverview;
5
+import com.water.bpm.entity.dto.TimeoutProcessVO;
6
+import com.water.bpm.mapper.ProcessStatisticsMapper;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.DisplayName;
9
+import org.junit.jupiter.api.Test;
10
+import org.junit.jupiter.api.extension.ExtendWith;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import java.sql.Timestamp;
15
+import java.time.LocalDateTime;
16
+import java.util.*;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.*;
20
+import static org.mockito.Mockito.*;
21
+
22
+/**
23
+ * 流程统计评估服务测试
24
+ */
25
+@ExtendWith(MockitoExtension.class)
26
+class ProcessStatisticsServiceTest {
27
+
28
+    @Mock
29
+    private ProcessStatisticsMapper statisticsMapper;
30
+
31
+    private ProcessStatisticsService statisticsService;
32
+
33
+    @BeforeEach
34
+    void setUp() {
35
+        statisticsService = new ProcessStatisticsService(statisticsMapper);
36
+    }
37
+
38
+    // ========== 概览测试 ==========
39
+
40
+    @Test
41
+    @DisplayName("统计概览 — 正常数据")
42
+    void testGetOverview_Normal() {
43
+        // Given
44
+        List<Map<String, Object>> statusCounts = List.of(
45
+                Map.of("status", "running", "count", 15L),
46
+                Map.of("status", "completed", "count", 80L),
47
+                Map.of("status", "rejected", "count", 5L),
48
+                Map.of("status", "terminated", "count", 3L)
49
+        );
50
+        when(statisticsMapper.countByStatus()).thenReturn(statusCounts);
51
+
52
+        Map<String, Object> overall = new HashMap<>();
53
+        overall.put("total", 103L);
54
+        overall.put("avg_duration_seconds", 7200.0);  // 2 hours
55
+        overall.put("completed_count", 80L);
56
+        when(statisticsMapper.overallStats()).thenReturn(overall);
57
+
58
+        Map<String, Object> timeout = Map.of("timeout_count", 2L);
59
+        when(statisticsMapper.timeoutCount()).thenReturn(timeout);
60
+
61
+        // When
62
+        ProcessStatsOverview overview = statisticsService.getOverview();
63
+
64
+        // Then
65
+        assertNotNull(overview);
66
+        assertEquals(103, overview.getTotalInstances());
67
+        assertEquals(15, overview.getRunningCount());
68
+        assertEquals(80, overview.getCompletedCount());
69
+        assertEquals(5, overview.getRejectedCount());
70
+        assertEquals(3, overview.getTerminatedCount());
71
+        assertEquals(2.0, overview.getAvgDurationHours());
72
+        assertEquals(77.67, overview.getCompletionRate());  // 80/103 * 100
73
+        assertEquals(2, overview.getTimeoutCount());
74
+    }
75
+
76
+    @Test
77
+    @DisplayName("统计概览 — 无数据")
78
+    void testGetOverview_Empty() {
79
+        when(statisticsMapper.countByStatus()).thenReturn(List.of());
80
+        when(statisticsMapper.overallStats()).thenReturn(null);
81
+        when(statisticsMapper.timeoutCount()).thenReturn(null);
82
+
83
+        ProcessStatsOverview overview = statisticsService.getOverview();
84
+
85
+        assertNotNull(overview);
86
+        assertEquals(0, overview.getTotalInstances());
87
+        assertEquals(0, overview.getRunningCount());
88
+        assertEquals(0.0, overview.getAvgDurationHours());
89
+        assertEquals(0.0, overview.getCompletionRate());
90
+    }
91
+
92
+    // ========== 平均处理时长趋势测试 ==========
93
+
94
+    @Test
95
+    @DisplayName("平均处理时长趋势 — 7天数据")
96
+    void testGetAvgDurationTrend() {
97
+        List<Map<String, Object>> trend = List.of(
98
+                createTrendRow("2026-06-10", 5, 14400.0, 28800.0, 3600.0),
99
+                createTrendRow("2026-06-11", 8, 10800.0, 21600.0, 5400.0),
100
+                createTrendRow("2026-06-12", 3, 18000.0, 36000.0, 7200.0)
101
+        );
102
+        when(statisticsMapper.avgDurationTrend(any(), any())).thenReturn(trend);
103
+
104
+        List<Map<String, Object>> result = statisticsService.getAvgDurationTrend(7);
105
+
106
+        assertNotNull(result);
107
+        assertEquals(3, result.size());
108
+        assertEquals("2026-06-10", result.get(0).get("date"));
109
+        assertEquals(4.0, result.get(0).get("avgHours"));   // 14400s = 4h
110
+        assertEquals(5, result.get(0).get("completedCount"));
111
+    }
112
+
113
+    @Test
114
+    @DisplayName("平均处理时长趋势 — 空数据")
115
+    void testGetAvgDurationTrend_Empty() {
116
+        when(statisticsMapper.avgDurationTrend(any(), any())).thenReturn(List.of());
117
+
118
+        List<Map<String, Object>> result = statisticsService.getAvgDurationTrend(30);
119
+
120
+        assertNotNull(result);
121
+        assertTrue(result.isEmpty());
122
+    }
123
+
124
+    // ========== 节点耗时分布测试 ==========
125
+
126
+    @Test
127
+    @DisplayName("节点耗时分布 — 识别瓶颈节点")
128
+    void testGetNodeDurationDistribution_WithBottleneck() {
129
+        List<Map<String, Object>> nodes = List.of(
130
+                createNodeRow("node1", "部门经理审批", 172800.0, 345600.0, 86400.0, 20),  // 48h avg — bottleneck
131
+                createNodeRow("node2", "技术审核", 3600.0, 7200.0, 1800.0, 15),            // 1h avg
132
+                createNodeRow("node3", "财务确认", 7200.0, 14400.0, 3600.0, 10)             // 2h avg
133
+        );
134
+        when(statisticsMapper.nodeDurationDistribution()).thenReturn(nodes);
135
+
136
+        List<NodeDurationVO> result = statisticsService.getNodeDurationDistribution();
137
+
138
+        assertNotNull(result);
139
+        assertEquals(3, result.size());
140
+
141
+        // 部门经理审批应该是瓶颈 (48h >> average)
142
+        NodeDurationVO bottleneck = result.get(0);
143
+        assertEquals("node1", bottleneck.getNodeId());
144
+        assertEquals("部门经理审批", bottleneck.getNodeName());
145
+        assertEquals(48.0, bottleneck.getAvgDurationHours());
146
+        assertTrue(bottleneck.getIsBottleneck());
147
+
148
+        // 技术审核不是瓶颈
149
+        NodeDurationVO tech = result.get(1);
150
+        assertEquals(1.0, tech.getAvgDurationHours());
151
+        assertFalse(tech.getIsBottleneck());
152
+    }
153
+
154
+    @Test
155
+    @DisplayName("节点耗时分布 — 单节点无瓶颈")
156
+    void testGetNodeDurationDistribution_SingleNode() {
157
+        List<Map<String, Object>> nodes = List.of(
158
+                createNodeRow("node1", "审批", 3600.0, 7200.0, 1800.0, 10)
159
+        );
160
+        when(statisticsMapper.nodeDurationDistribution()).thenReturn(nodes);
161
+
162
+        List<NodeDurationVO> result = statisticsService.getNodeDurationDistribution();
163
+
164
+        assertEquals(1, result.size());
165
+        // 单节点不标记瓶颈
166
+        assertFalse(result.get(0).getIsBottleneck());
167
+    }
168
+
169
+    // ========== 超时流程列表测试 ==========
170
+
171
+    @Test
172
+    @DisplayName("超时流程列表 — 正常数据")
173
+    void testGetTimeoutProcessList() {
174
+        List<Map<String, Object>> raw = List.of(
175
+                createTimeoutRow(1L, "uuid-001", "设备维修", "水泵故障维修", "张三", "技术审核", "李四", 72.5),
176
+                createTimeoutRow(2L, "uuid-002", "巡检任务", "管网巡检异常", "王五", "部门经理审批", "赵六", 96.0)
177
+        );
178
+        when(statisticsMapper.timeoutProcessList()).thenReturn(raw);
179
+
180
+        List<TimeoutProcessVO> result = statisticsService.getTimeoutProcessList();
181
+
182
+        assertNotNull(result);
183
+        assertEquals(2, result.size());
184
+        assertEquals("设备维修", result.get(0).getProcessName());
185
+        assertEquals(72.5, result.get(0).getDurationHours());
186
+        assertEquals("巡检任务", result.get(1).getProcessName());
187
+        assertTrue(result.get(1).getDurationHours() > 48.0);
188
+    }
189
+
190
+    @Test
191
+    @DisplayName("超时流程列表 — 精确查询为空时使用简化查询")
192
+    void testGetTimeoutProcessList_FallbackToSimple() {
193
+        when(statisticsMapper.timeoutProcessList()).thenReturn(List.of());
194
+        List<Map<String, Object>> simple = List.of(
195
+                createTimeoutRow(3L, "uuid-003", "水质检测", "水质超标处理", "钱七", "领导审批", "孙八", 50.0)
196
+        );
197
+        when(statisticsMapper.timeoutProcessListSimple()).thenReturn(simple);
198
+
199
+        List<TimeoutProcessVO> result = statisticsService.getTimeoutProcessList();
200
+
201
+        assertEquals(1, result.size());
202
+        assertEquals("水质检测", result.get(0).getProcessName());
203
+        verify(statisticsMapper).timeoutProcessListSimple();
204
+    }
205
+
206
+    // ========== 流程完成率测试 ==========
207
+
208
+    @Test
209
+    @DisplayName("流程完成率 — 正常计算")
210
+    void testGetCompletionRate() {
211
+        List<Map<String, Object>> raw = List.of(
212
+                Map.of("process_key", "equipment_repair", "process_name", "设备维修",
213
+                        "total", 100L, "completed", 85L, "completion_rate", 85.0),
214
+                Map.of("process_key", "inspection", "process_name", "巡检任务",
215
+                        "total", 50L, "completed", 45L, "completion_rate", 90.0)
216
+        );
217
+        when(statisticsMapper.completionRateByDefinition()).thenReturn(raw);
218
+
219
+        List<Map<String, Object>> result = statisticsService.getCompletionRate();
220
+
221
+        assertEquals(2, result.size());
222
+        assertEquals("equipment_repair", result.get(0).get("processKey"));
223
+        assertEquals(85.0, result.get(0).get("completionRate"));
224
+        assertEquals(100, result.get(0).get("total"));
225
+    }
226
+
227
+    // ========== 按流程定义分组统计测试 ==========
228
+
229
+    @Test
230
+    @DisplayName("按流程定义分组统计")
231
+    void testGetStatsByDefinition() {
232
+        List<Map<String, Object>> raw = List.of(
233
+                createDefinitionRow(1L, "设备维修", "equipment_repair", 50, 10, 35, 3, 2, 7200.0, 14400.0, 3600.0)
234
+        );
235
+        when(statisticsMapper.statsByDefinition()).thenReturn(raw);
236
+
237
+        List<Map<String, Object>> result = statisticsService.getStatsByDefinition();
238
+
239
+        assertEquals(1, result.size());
240
+        Map<String, Object> def = result.get(0);
241
+        assertEquals("设备维修", def.get("processName"));
242
+        assertEquals(50, def.get("totalInstances"));
243
+        assertEquals(10, def.get("runningCount"));
244
+        assertEquals(2.0, def.get("avgDurationHours"));  // 7200s = 2h
245
+    }
246
+
247
+    // ========== 瓶颈节点识别测试 ==========
248
+
249
+    @Test
250
+    @DisplayName("瓶颈节点识别 — 取Top N")
251
+    void testGetBottleneckNodes() {
252
+        List<Map<String, Object>> nodes = List.of(
253
+                createNodeRow("node1", "总经理审批", 172800.0, 345600.0, 86400.0, 20),   // 48h — bottleneck
254
+                createNodeRow("node2", "部门经理审批", 100800.0, 172800.0, 43200.0, 15),  // 28h — bottleneck (> avg*2)
255
+                createNodeRow("node3", "技术审核", 3600.0, 7200.0, 1800.0, 10),            // 1h
256
+                createNodeRow("node4", "表单填写", 1800.0, 3600.0, 900.0, 25)              // 0.5h
257
+        );
258
+        when(statisticsMapper.nodeDurationDistribution()).thenReturn(nodes);
259
+
260
+        List<NodeDurationVO> bottlenecks = statisticsService.getBottleneckNodes(5);
261
+
262
+        assertNotNull(bottlenecks);
263
+        // 总经理审批和部门经理审批应该是瓶颈
264
+        assertTrue(bottlenecks.size() >= 1);
265
+        assertTrue(bottlenecks.stream().allMatch(NodeDurationVO::getIsBottleneck));
266
+    }
267
+
268
+    // ========== Helper methods ==========
269
+
270
+    private Map<String, Object> createTrendRow(String date, long count, double avg, double max, double min) {
271
+        Map<String, Object> row = new HashMap<>();
272
+        row.put("stat_date", date);
273
+        row.put("completed_count", count);
274
+        row.put("avg_duration_seconds", avg);
275
+        row.put("max_duration_seconds", max);
276
+        row.put("min_duration_seconds", min);
277
+        return row;
278
+    }
279
+
280
+    private Map<String, Object> createNodeRow(String nodeId, String nodeName,
281
+                                               double avgSec, double maxSec, double minSec, int count) {
282
+        Map<String, Object> row = new HashMap<>();
283
+        row.put("node_id", nodeId);
284
+        row.put("node_name", nodeName);
285
+        row.put("avg_duration_seconds", avgSec);
286
+        row.put("max_duration_seconds", maxSec);
287
+        row.put("min_duration_seconds", minSec);
288
+        row.put("approval_count", count);
289
+        return row;
290
+    }
291
+
292
+    private Map<String, Object> createTimeoutRow(Long id, String instanceId, String processName,
293
+                                                   String title, String initiator, String nodeName,
294
+                                                   String assignee, double hours) {
295
+        Map<String, Object> row = new HashMap<>();
296
+        row.put("id", id);
297
+        row.put("instance_id", instanceId);
298
+        row.put("process_name", processName);
299
+        row.put("title", title);
300
+        row.put("initiator_name", initiator);
301
+        row.put("current_node_name", nodeName);
302
+        row.put("current_assignee_name", assignee);
303
+        row.put("started_at", Timestamp.valueOf(LocalDateTime.now().minusHours((long) hours)));
304
+        row.put("status", "running");
305
+        row.put("priority", 0);
306
+        row.put("duration_hours", hours);
307
+        return row;
308
+    }
309
+
310
+    private Map<String, Object> createDefinitionRow(Long defId, String name, String key,
311
+                                                      int total, int running, int completed,
312
+                                                      int rejected, int terminated,
313
+                                                      double avgSec, double maxSec, double minSec) {
314
+        Map<String, Object> row = new HashMap<>();
315
+        row.put("definition_id", defId);
316
+        row.put("process_name", name);
317
+        row.put("process_key", key);
318
+        row.put("total_instances", total);
319
+        row.put("running_count", running);
320
+        row.put("completed_count", completed);
321
+        row.put("rejected_count", rejected);
322
+        row.put("terminated_count", terminated);
323
+        row.put("avg_duration_seconds", avgSec);
324
+        row.put("max_duration_seconds", maxSec);
325
+        row.put("min_duration_seconds", minSec);
326
+        return row;
327
+    }
328
+}