Bladeren bron

feat(approvalFlow): P3-15 节点级统计 + 流程升级机制收尾 (triggerTimeoutScan); bump 2.4.81/1.0.48

P3-15 节点级统计
- 后端新增 FlowStatisticsService + FlowStatisticsDtos:概览卡片、日趋势、流程维度、节点维度(含最长/最短审批人)、审批人维度、TopN 最慢节点
- 筛选维度支持 BizType、发起人部门(InitiatorOrgIds)、日期区间、流程 Id
- 菜单种子新增"审批统计"页面(/aidop/flowManage/flowStatistics)
- 前端新增 statistics/index.vue:4 张卡片 + 日趋势折线 + TopN 最慢节点柱状 + 3 张表(流程/节点/审批人)
- 前端 api.ts 补齐 5 个统计接口绑定

流程升级机制收尾(Escalation)
- FlowEngineService 抽取 ScanTimeoutTasks(CancellationToken) 公共方法;FlowTimeoutJob 委托给该方法,避免扫描逻辑两处维护
- FlowTaskService 新增 POST /api/flowTask/triggerTimeoutScan,仅 SuperAdmin/SysAdmin 可调用,返回本次处理条数
- 同时满足运维排障与 E2E 自动化(绕开 1h 超时 + 5min 扫描周期的等待)

验证
- _verify_escalation.py 覆盖 4 种超时动作(Notify/AutoApprove/AutoReject/AutoEscalate)+ 手动升级 + 管理员权限校验,22/22 PASS
- _verify_statistics.py(上一轮)14/14 PASS
- 后端 build 0 error

文档
- doc/plan/审批流-流程升级机制实施方案.md:验收标准勾选 + 新增"九、收尾验证"
- doc/plan/审批流-综合优化方案.md:第五批勾选"节点级统计"与"流程升级机制收尾"

Made-with: Cursor
skygu 3 maanden geleden
bovenliggende
commit
3ba82bbac3

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.80",
+	"version": "2.4.81",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 34 - 0
Web/src/views/approvalFlow/api.ts

@@ -159,3 +159,37 @@ export function updateDelegate(data: { id: number; delegateUserId: number; start
 export function deleteDelegate(data: { id: number }) {
 	return axiosInstance.post(`${BASE}/flowDelegate/delete`, data);
 }
+
+// ── 审批统计 (P3-15) ──
+
+export interface FlowStatisticsInput {
+	startTime?: string;
+	endTime?: string;
+	flowIds?: number[];
+	bizTypes?: string[];
+	initiatorOrgIds?: number[];
+}
+
+export function statOverview(data: FlowStatisticsInput) {
+	return axiosInstance.post<any>(`${BASE}/flowStatistics/overview`, data);
+}
+
+export function statByFlow(data: FlowStatisticsInput) {
+	return axiosInstance.post<any>(`${BASE}/flowStatistics/byFlow`, data);
+}
+
+export function statByNode(data: FlowStatisticsInput) {
+	return axiosInstance.post<any>(`${BASE}/flowStatistics/byNode`, data);
+}
+
+export function statByApprover(data: FlowStatisticsInput) {
+	return axiosInstance.post<any>(`${BASE}/flowStatistics/byApprover`, data);
+}
+
+export function statTopSlowNodes(data: FlowStatisticsInput) {
+	return axiosInstance.post<any>(`${BASE}/flowStatistics/topSlowNodes`, data);
+}
+
+export function getFlowListForFilter() {
+	return axiosInstance.post<any>(`${BASE}/approvalFlow/page`, { page: 1, pageSize: 500 });
+}

+ 472 - 0
Web/src/views/approvalFlow/statistics/index.vue

@@ -0,0 +1,472 @@
+<template>
+	<div class="flow-statistics-container">
+		<!-- 筛选区 -->
+		<el-card shadow="never" class="filter-card">
+			<el-form :model="filter" inline label-width="90px" size="default">
+				<el-form-item label="时间范围">
+					<el-date-picker
+						v-model="filter.timeRange"
+						type="daterange"
+						range-separator="~"
+						start-placeholder="开始日期"
+						end-placeholder="结束日期"
+						value-format="YYYY-MM-DD"
+						:shortcuts="dateShortcuts"
+						style="width: 280px"
+					/>
+				</el-form-item>
+				<el-form-item label="流程">
+					<el-select v-model="filter.flowIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="全部" style="width: 220px" clearable>
+						<el-option v-for="f in flowOptions" :key="f.id" :label="f.name" :value="f.id" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="业务类型">
+					<el-select v-model="filter.bizTypes" multiple filterable collapse-tags collapse-tags-tooltip placeholder="全部" style="width: 180px" clearable>
+						<el-option v-for="b in bizTypeOptions" :key="b.code" :label="b.name" :value="b.code" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="发起部门">
+					<el-tree-select
+						v-model="filter.initiatorOrgIds"
+						:data="orgTree"
+						multiple
+						show-checkbox
+						check-strictly
+						:render-after-expand="false"
+						:props="{ label: 'name', value: 'id' }"
+						node-key="id"
+						placeholder="全部"
+						style="width: 220px"
+						clearable
+						collapse-tags
+						collapse-tags-tooltip
+					/>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" :loading="loading" @click="loadAll">查询</el-button>
+					<el-button @click="reset">重置</el-button>
+				</el-form-item>
+			</el-form>
+		</el-card>
+
+		<!-- 概览卡片 -->
+		<el-row :gutter="12" class="summary-cards">
+			<el-col :span="6">
+				<div class="summary-card c-blue">
+					<div class="label">发起总数</div>
+					<div class="value">{{ overview.totalInstances ?? 0 }}</div>
+					<div class="sub">已完成 {{ overview.completedInstances ?? 0 }} / 进行中 {{ runningCount }}</div>
+				</div>
+			</el-col>
+			<el-col :span="6">
+				<div class="summary-card c-green">
+					<div class="label">完成率</div>
+					<div class="value">{{ formatPct(overview.completionRate) }}</div>
+					<div class="sub">已完成 ÷ 总数</div>
+				</div>
+			</el-col>
+			<el-col :span="6">
+				<div class="summary-card c-orange">
+					<div class="label">平均审批时长(小时)</div>
+					<div class="value">{{ overview.avgDurationHours ?? 0 }}</div>
+					<div class="sub">仅统计已完成实例</div>
+				</div>
+			</el-col>
+			<el-col :span="6">
+				<div class="summary-card c-red">
+					<div class="label">超时任务数</div>
+					<div class="value">{{ overview.timeoutTaskCount ?? 0 }}</div>
+					<div class="sub">超时率 {{ formatPct(overview.timeoutRate) }}</div>
+				</div>
+			</el-col>
+		</el-row>
+
+		<!-- 趋势折线图 -->
+		<el-card shadow="never" class="chart-card">
+			<template #header>
+				<div class="card-header">
+					<span>发起 / 完成趋势</span>
+					<el-text type="info" size="small">范围内逐日统计</el-text>
+				</div>
+			</template>
+			<div ref="trendChartEl" class="chart-box" style="height: 260px" />
+		</el-card>
+
+		<!-- 三张表 -->
+		<el-card shadow="never" class="table-card">
+			<el-tabs v-model="activeTab">
+				<!-- 按流程 -->
+				<el-tab-pane label="按流程" name="flow">
+					<el-table :data="flowRows" v-loading="loadingByFlow" border stripe style="width: 100%" size="default">
+						<el-table-column prop="flowName" label="流程名称" min-width="160" show-overflow-tooltip />
+						<el-table-column prop="flowCode" label="编码" width="140" show-overflow-tooltip />
+						<el-table-column label="业务类型" width="120" show-overflow-tooltip>
+							<template #default="{ row }">{{ row.bizTypeName || row.bizType || '-' }}</template>
+						</el-table-column>
+						<el-table-column prop="totalCount" label="发起数" width="80" align="right" sortable />
+						<el-table-column prop="completedCount" label="已完成" width="80" align="right" sortable />
+						<el-table-column prop="runningCount" label="进行中" width="80" align="right" sortable />
+						<el-table-column prop="rejectedCount" label="已拒绝" width="80" align="right" sortable />
+						<el-table-column label="通过率" width="100" align="right" sortable :sort-by="(r: any) => r.passRate">
+							<template #default="{ row }">{{ formatPct(row.passRate) }}</template>
+						</el-table-column>
+						<el-table-column prop="avgDurationHours" label="平均耗时(h)" width="110" align="right" sortable />
+						<el-table-column prop="maxDurationHours" label="最长耗时(h)" width="110" align="right" sortable />
+					</el-table>
+				</el-tab-pane>
+
+				<!-- 按节点 -->
+				<el-tab-pane name="node">
+					<template #label>
+						<span>按节点 <el-tag size="small" type="danger" effect="plain">瓶颈</el-tag></span>
+					</template>
+
+					<!-- Top10 柱图 -->
+					<div ref="topNodeChartEl" class="chart-box" style="height: 280px; margin-bottom: 12px" />
+
+					<el-table :data="nodeRows" v-loading="loadingByNode" border stripe style="width: 100%" size="default">
+						<el-table-column prop="flowName" label="流程" width="160" show-overflow-tooltip />
+						<el-table-column prop="nodeName" label="节点" min-width="140" show-overflow-tooltip />
+						<el-table-column label="类型" width="110">
+							<template #default="{ row }">
+								<el-tag size="small" :type="nodeTypeTagType(row.nodeType)">{{ nodeTypeLabel(row.nodeType) }}</el-tag>
+							</template>
+						</el-table-column>
+						<el-table-column prop="flowCount" label="流经次数" width="90" align="right" sortable />
+						<el-table-column prop="avgDurationHours" label="平均停留(h)" width="110" align="right" sortable />
+						<el-table-column prop="maxDurationHours" label="最长停留(h)" width="110" align="right" sortable />
+						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable />
+						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable />
+						<el-table-column label="拒绝率" width="90" align="right" sortable :sort-by="(r: any) => r.rejectRate">
+							<template #default="{ row }">{{ formatPct(row.rejectRate) }}</template>
+						</el-table-column>
+						<el-table-column prop="timeoutCount" label="超时数" width="80" align="right" sortable />
+						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable />
+						<el-table-column prop="longestApproverName" label="最长审批人" width="110" show-overflow-tooltip />
+						<el-table-column prop="shortestApproverName" label="最短审批人" width="110" show-overflow-tooltip />
+					</el-table>
+				</el-tab-pane>
+
+				<!-- 按审批人 -->
+				<el-tab-pane label="按审批人" name="approver">
+					<el-table :data="approverRows" v-loading="loadingByApprover" border stripe style="width: 100%" size="default">
+						<el-table-column prop="approverName" label="审批人" width="130" show-overflow-tooltip />
+						<el-table-column prop="orgName" label="部门" min-width="140" show-overflow-tooltip />
+						<el-table-column prop="totalAssigned" label="收到任务" width="90" align="right" sortable />
+						<el-table-column prop="completedCount" label="已完成" width="80" align="right" sortable />
+						<el-table-column prop="pendingCount" label="待办中" width="80" align="right" sortable />
+						<el-table-column prop="avgProcessHours" label="平均处理(h)" width="110" align="right" sortable />
+						<el-table-column prop="timeoutCount" label="超时处理" width="90" align="right" sortable />
+						<el-table-column prop="approveCount" label="同意" width="70" align="right" sortable />
+						<el-table-column prop="rejectCount" label="拒绝" width="70" align="right" sortable />
+						<el-table-column prop="transferCount" label="转办" width="70" align="right" sortable />
+					</el-table>
+				</el-tab-pane>
+			</el-tabs>
+		</el-card>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, reactive, ref, nextTick, watch, computed } from 'vue';
+import * as echarts from 'echarts';
+import { getAPI } from '/@/utils/axios-utils';
+import { SysOrgApi } from '/@/api-services/api';
+import {
+	getBizTypeList,
+	statOverview,
+	statByFlow,
+	statByNode,
+	statByApprover,
+	statTopSlowNodes,
+	getFlowListForFilter,
+	type FlowStatisticsInput,
+} from '/@/views/approvalFlow/api';
+
+// ───────── 筛选 ─────────
+const today = new Date();
+const defaultStart = new Date(today.getTime() - 29 * 24 * 3600 * 1000);
+const toYMD = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+
+const filter = reactive<{
+	timeRange: [string, string];
+	flowIds: number[];
+	bizTypes: string[];
+	initiatorOrgIds: number[];
+}>({
+	timeRange: [toYMD(defaultStart), toYMD(today)],
+	flowIds: [],
+	bizTypes: [],
+	initiatorOrgIds: [],
+});
+
+const dateShortcuts = [
+	{ text: '近 7 天', value: () => [new Date(Date.now() - 6 * 864e5), new Date()] },
+	{ text: '近 30 天', value: () => [new Date(Date.now() - 29 * 864e5), new Date()] },
+	{ text: '近 90 天', value: () => [new Date(Date.now() - 89 * 864e5), new Date()] },
+	{
+		text: '本月',
+		value: () => {
+			const n = new Date();
+			return [new Date(n.getFullYear(), n.getMonth(), 1), n];
+		},
+	},
+];
+
+const flowOptions = ref<Array<{ id: number; name: string }>>([]);
+const bizTypeOptions = ref<Array<{ code: string; name: string }>>([]);
+const orgTree = ref<any[]>([]);
+
+const buildInput = (): FlowStatisticsInput => ({
+	startTime: filter.timeRange?.[0] ? `${filter.timeRange[0]} 00:00:00` : undefined,
+	endTime: filter.timeRange?.[1] ? `${filter.timeRange[1]} 23:59:59` : undefined,
+	flowIds: filter.flowIds.length ? filter.flowIds : undefined,
+	bizTypes: filter.bizTypes.length ? filter.bizTypes : undefined,
+	initiatorOrgIds: filter.initiatorOrgIds.length ? filter.initiatorOrgIds : undefined,
+});
+
+// ───────── 数据 ─────────
+const overview = ref<any>({});
+const flowRows = ref<any[]>([]);
+const nodeRows = ref<any[]>([]);
+const approverRows = ref<any[]>([]);
+const topSlowNodes = ref<any[]>([]);
+
+const loading = ref(false);
+const loadingByFlow = ref(false);
+const loadingByNode = ref(false);
+const loadingByApprover = ref(false);
+
+const activeTab = ref('flow');
+
+const runningCount = computed(() =>
+	Math.max((overview.value.totalInstances ?? 0) - (overview.value.completedInstances ?? 0), 0)
+);
+
+// ───────── 工具 ─────────
+const formatPct = (v: number | undefined) => {
+	if (v === null || v === undefined || isNaN(v)) return '0%';
+	return `${(v * 100).toFixed(1)}%`;
+};
+const nodeTypeLabel = (t?: string) => {
+	if (!t || t === 'bpmn:userTask') return '用户任务';
+	if (t === 'bpmn:exclusiveGateway') return '排他网关';
+	if (t === 'bpmn:parallelGateway') return '并行网关';
+	if (t === 'bpmn:startEvent') return '开始';
+	if (t === 'bpmn:endEvent') return '结束';
+	return t;
+};
+const nodeTypeTagType = (t?: string) => {
+	if (!t || t === 'bpmn:userTask') return '';
+	if (t === 'bpmn:exclusiveGateway') return 'warning';
+	if (t === 'bpmn:parallelGateway') return 'success';
+	return 'info';
+};
+
+// ───────── 图表 ─────────
+const trendChartEl = ref<HTMLElement>();
+const topNodeChartEl = ref<HTMLElement>();
+let trendChart: echarts.ECharts | null = null;
+let topNodeChart: echarts.ECharts | null = null;
+
+const renderTrend = () => {
+	if (!trendChartEl.value) return;
+	if (!trendChart) trendChart = echarts.init(trendChartEl.value);
+	const trend: Array<{ date: string; startedCount: number; completedCount: number }> = overview.value.dailyTrend ?? [];
+	trendChart.setOption({
+		tooltip: { trigger: 'axis' },
+		legend: { data: ['发起', '完成'], right: 20 },
+		grid: { left: 40, right: 20, top: 40, bottom: 40 },
+		xAxis: { type: 'category', data: trend.map((x) => x.date), axisLabel: { rotate: 0, fontSize: 11 } },
+		yAxis: { type: 'value', minInterval: 1 },
+		series: [
+			{ name: '发起', type: 'line', smooth: true, data: trend.map((x) => x.startedCount), itemStyle: { color: '#409eff' } },
+			{ name: '完成', type: 'line', smooth: true, data: trend.map((x) => x.completedCount), itemStyle: { color: '#67c23a' } },
+		],
+	});
+};
+
+const renderTopNode = () => {
+	if (!topNodeChartEl.value) return;
+	if (!topNodeChart) topNodeChart = echarts.init(topNodeChartEl.value);
+	const list = (topSlowNodes.value ?? []).slice().reverse();
+	topNodeChart.setOption({
+		title: { text: 'Top 10 最慢节点(按平均停留时长)', left: 'center', top: 4, textStyle: { fontSize: 14 } },
+		tooltip: {
+			trigger: 'axis',
+			axisPointer: { type: 'shadow' },
+			formatter: (params: any) => {
+				const p = params[0];
+				const item = list[p.dataIndex];
+				return `${item.flowName} / ${item.nodeName}<br/>平均停留:${p.value} h<br/>流经次数:${item.flowCount}`;
+			},
+		},
+		grid: { left: 140, right: 30, top: 40, bottom: 30 },
+		xAxis: { type: 'value', name: '小时' },
+		yAxis: {
+			type: 'category',
+			data: list.map((x) => `${x.flowName} / ${x.nodeName}`),
+			axisLabel: { fontSize: 11, width: 130, overflow: 'truncate' },
+		},
+		series: [
+			{
+				type: 'bar',
+				data: list.map((x) => x.avgDurationHours),
+				itemStyle: { color: '#f56c6c' },
+				label: { show: true, position: 'right' },
+			},
+		],
+	});
+};
+
+// ───────── 加载 ─────────
+const loadOptions = async () => {
+	try {
+		const [flowRes, bizRes, orgRes] = await Promise.all([
+			getFlowListForFilter(),
+			getBizTypeList(),
+			getAPI(SysOrgApi).apiSysOrgTreeGet(0 as any),
+		]);
+		const items = flowRes?.data?.result?.items ?? flowRes?.data?.result ?? [];
+		flowOptions.value = items.map((f: any) => ({ id: f.id, name: f.name }));
+		bizTypeOptions.value = bizRes?.data?.result ?? [];
+		orgTree.value = (orgRes as any)?.data?.result ?? [];
+	} catch (e) {
+		console.warn('[flowStatistics] load options failed', e);
+	}
+};
+
+const loadAll = async () => {
+	const input = buildInput();
+	loading.value = true;
+	loadingByFlow.value = true;
+	loadingByNode.value = true;
+	loadingByApprover.value = true;
+	try {
+		const [o, f, n, a, top] = await Promise.all([
+			statOverview(input),
+			statByFlow(input),
+			statByNode(input),
+			statByApprover(input),
+			statTopSlowNodes(input),
+		]);
+		overview.value = o?.data?.result ?? {};
+		flowRows.value = f?.data?.result ?? [];
+		nodeRows.value = n?.data?.result ?? [];
+		approverRows.value = a?.data?.result ?? [];
+		topSlowNodes.value = top?.data?.result ?? [];
+		await nextTick();
+		renderTrend();
+		renderTopNode();
+	} finally {
+		loading.value = false;
+		loadingByFlow.value = false;
+		loadingByNode.value = false;
+		loadingByApprover.value = false;
+	}
+};
+
+const reset = () => {
+	filter.timeRange = [toYMD(defaultStart), toYMD(today)];
+	filter.flowIds = [];
+	filter.bizTypes = [];
+	filter.initiatorOrgIds = [];
+	loadAll();
+};
+
+watch(activeTab, async (v) => {
+	if (v === 'node') {
+		await nextTick();
+		topNodeChart?.resize();
+		renderTopNode();
+	}
+});
+
+const onResize = () => {
+	trendChart?.resize();
+	topNodeChart?.resize();
+};
+
+onMounted(async () => {
+	await loadOptions();
+	await loadAll();
+	window.addEventListener('resize', onResize);
+});
+
+onBeforeUnmount(() => {
+	window.removeEventListener('resize', onResize);
+	trendChart?.dispose();
+	topNodeChart?.dispose();
+});
+</script>
+
+<style scoped lang="scss">
+.flow-statistics-container {
+	padding: 8px;
+
+	.filter-card {
+		margin-bottom: 12px;
+		:deep(.el-form-item) {
+			margin-bottom: 0;
+			margin-right: 12px;
+		}
+	}
+
+	.summary-cards {
+		margin-bottom: 12px;
+
+		.summary-card {
+			padding: 14px 18px;
+			border-radius: 8px;
+			color: #fff;
+			box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+
+			.label {
+				font-size: 13px;
+				opacity: 0.9;
+			}
+			.value {
+				font-size: 28px;
+				font-weight: 600;
+				margin: 4px 0;
+				line-height: 1.2;
+			}
+			.sub {
+				font-size: 12px;
+				opacity: 0.8;
+			}
+		}
+		.c-blue {
+			background: linear-gradient(135deg, #409eff 0%, #337ecc 100%);
+		}
+		.c-green {
+			background: linear-gradient(135deg, #67c23a 0%, #529b2e 100%);
+		}
+		.c-orange {
+			background: linear-gradient(135deg, #e6a23c 0%, #b88230 100%);
+		}
+		.c-red {
+			background: linear-gradient(135deg, #f56c6c 0%, #c45656 100%);
+		}
+	}
+
+	.chart-card {
+		margin-bottom: 12px;
+		.card-header {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+		}
+	}
+
+	.chart-box {
+		width: 100%;
+	}
+
+	.table-card {
+		:deep(.el-tabs__content) {
+			overflow: visible;
+		}
+	}
+}
+</style>

+ 393 - 0
_verify_escalation.py

@@ -0,0 +1,393 @@
+"""审批流升级机制 E2E 验证(Batch 5)。
+
+覆盖:
+1. 超时自动处理(4 种动作):Notify / AutoApprove / AutoReject / AutoEscalate
+2. 手动升级:Escalate API
+3. 新增管理员接口 `/api/flowTask/triggerTimeoutScan` 的权限与幂等性
+
+实现方式:
+- 直接 INSERT 临时审批流定义(5 个 BizType,跑完脚本后自动清理)
+- 通过 API 起流程、取 pending task
+- 直接 UPDATE task.CreateTime 将其回拨 2 小时
+- 调 triggerTimeoutScan 立刻执行一次
+- 断言 task/instance/log 表的状态
+"""
+
+import sys
+import time
+import json
+import urllib.parse
+
+import pymysql
+import pymysql.cursors
+import requests
+
+BASE = "http://127.0.0.1:5005"
+API = f"{BASE}/api"
+DB = dict(
+    host="123.60.180.165",
+    port=3306,
+    user="aidopremote",
+    password="1234567890aiDOP#",
+    database="aidopdev",
+    cursorclass=pymysql.cursors.DictCursor,
+    autocommit=True,
+)
+
+ACCOUNT = "superAdmin.NET"
+PASSWORD = "1234567890dop"
+SUPER_ADMIN_ID = 1300000000101          # superAdmin.NET(发起人 + 首任审批人)
+ESCALATE_TARGET_ID = 1300000000111       # Admin.NET(升级目标)
+ESCALATE_TARGET_NAME = "系统管理员"
+
+SUFFIX = int(time.time())
+
+checks: list[tuple[str, str, str]] = []
+
+
+def check(name: str, ok: bool, detail: str = "") -> None:
+    status = "PASS" if ok else "FAIL"
+    checks.append((status, name, detail))
+    print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
+
+
+# ───── HTTP ─────
+
+def server_encrypt(plain: str) -> str:
+    r = requests.post(f"{API}/sysCommon/encryptPlainText/{urllib.parse.quote(plain, safe='')}")
+    r.raise_for_status()
+    body = r.json()
+    assert body.get("code") == 200, body
+    return body["result"]
+
+
+def login(account: str = ACCOUNT, password: str = PASSWORD) -> str:
+    enc = server_encrypt(password)
+    r = requests.post(f"{API}/sysAuth/login", json={"account": account, "password": enc})
+    r.raise_for_status()
+    body = r.json()
+    assert body.get("code") == 200, body
+    return body["result"]["accessToken"]
+
+
+def api_post(token: str, path: str, payload: dict | None = None) -> dict:
+    r = requests.post(
+        f"{API}{path}",
+        json=payload or {},
+        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+    )
+    r.raise_for_status()
+    body = r.json()
+    assert body.get("code") == 200, f"{path} failed: {body}"
+    return body.get("result")
+
+
+# ───── 流程定义生成 ─────
+
+def make_flow_json(timeout_action: str | None,
+                   enable_manual: bool = False) -> str:
+    """生成单节点流程:开始 → 审批 → 结束。
+    `timeout_action` 为 None 时不设置超时,用于"手动升级"场景。
+    """
+    node_props = {
+        "nodeName": "审批节点",
+        "approverType": "SpecificUser",
+        "approverIds": str(SUPER_ADMIN_ID),
+        "approverNames": "超级管理员",
+        "multiApproveMode": "Any",
+    }
+    if timeout_action:
+        node_props["timeoutHours"] = 1
+        node_props["timeoutAction"] = timeout_action
+    if timeout_action == "AutoEscalate" or enable_manual:
+        node_props["escalationApproverType"] = "SpecificUser"
+        node_props["escalationApproverIds"] = str(ESCALATE_TARGET_ID)
+        node_props["escalationApproverNames"] = ESCALATE_TARGET_NAME
+    if enable_manual:
+        node_props["enableManualEscalation"] = True
+
+    return json.dumps({
+        "nodes": [
+            {"id": "node_start", "type": "bpmn:startEvent", "x": 100, "y": 200,
+             "properties": {}, "text": {"x": 100, "y": 200, "value": "开始"}},
+            {"id": "node_task", "type": "bpmn:userTask", "x": 300, "y": 200,
+             "properties": node_props, "text": {"x": 300, "y": 200, "value": "审批节点"}},
+            {"id": "node_end", "type": "bpmn:endEvent", "x": 500, "y": 200,
+             "properties": {}, "text": {"x": 500, "y": 200, "value": "结束"}},
+        ],
+        "edges": [
+            {"id": "e1", "type": "bpmn:sequenceFlow",
+             "sourceNodeId": "node_start", "targetNodeId": "node_task",
+             "startPoint": {"x": 120, "y": 200}, "endPoint": {"x": 280, "y": 200},
+             "pointsList": []},
+            {"id": "e2", "type": "bpmn:sequenceFlow",
+             "sourceNodeId": "node_task", "targetNodeId": "node_end",
+             "startPoint": {"x": 320, "y": 200}, "endPoint": {"x": 480, "y": 200},
+             "pointsList": []},
+        ],
+    }, ensure_ascii=False)
+
+
+# ───── 数据库辅助 ─────
+
+def insert_flow(db: pymysql.connections.Connection, name: str, biz_type: str,
+                timeout_action: str | None, enable_manual: bool = False) -> int:
+    flow_json = make_flow_json(timeout_action, enable_manual)
+    with db.cursor() as c:
+        c.execute(
+            """
+            INSERT INTO ApprovalFlow
+                (Id, Code, Name, FormJson, FlowJson, Status, OrgId, IsDelete,
+                 CreateTime, UpdateTime, CreateUserId, CreateUserName,
+                 BizType, Version, IsPublished)
+            VALUES (UUID_SHORT(), %s, %s, '[]', %s, 1, 0, 0,
+                    NOW(), NOW(), %s, 'E2E-Escalation',
+                    %s, 1, 1)
+            """,
+            (f"ESC_{biz_type}"[:32], f"升级E2E-{name}"[:32], flow_json,
+             SUPER_ADMIN_ID, biz_type),
+        )
+        c.execute("SELECT Id FROM ApprovalFlow WHERE BizType=%s ORDER BY Id DESC LIMIT 1", (biz_type,))
+        row = c.fetchone()
+        return int(row["Id"])
+
+
+def fetch_first_pending_task(db: pymysql.connections.Connection, instance_id: int) -> dict | None:
+    with db.cursor() as c:
+        c.execute(
+            "SELECT Id, AssigneeId, Status, CreateTime FROM ApprovalFlowTask "
+            "WHERE InstanceId=%s AND Status=0 ORDER BY Id LIMIT 1",
+            (instance_id,),
+        )
+        return c.fetchone()
+
+
+def rollback_task_time(db: pymysql.connections.Connection, task_id: int, hours: int = 2) -> None:
+    with db.cursor() as c:
+        c.execute(
+            "UPDATE ApprovalFlowTask SET CreateTime = DATE_SUB(NOW(), INTERVAL %s HOUR) WHERE Id=%s",
+            (hours, task_id),
+        )
+
+
+def fetch_task_state(db: pymysql.connections.Connection, task_id: int) -> dict | None:
+    with db.cursor() as c:
+        c.execute("SELECT Id, Status, Comment, ActionTime FROM ApprovalFlowTask WHERE Id=%s", (task_id,))
+        return c.fetchone()
+
+
+def fetch_instance_state(db: pymysql.connections.Connection, instance_id: int) -> dict | None:
+    with db.cursor() as c:
+        c.execute("SELECT Id, Status, EndTime, CurrentNodeId FROM ApprovalFlowInstance WHERE Id=%s", (instance_id,))
+        return c.fetchone()
+
+
+def fetch_pending_tasks(db: pymysql.connections.Connection, instance_id: int, exclude_task_id: int | None = None) -> list[dict]:
+    with db.cursor() as c:
+        sql = "SELECT Id, AssigneeId, AssigneeName FROM ApprovalFlowTask WHERE InstanceId=%s AND Status=0"
+        params: list = [instance_id]
+        if exclude_task_id:
+            sql += " AND Id<>%s"
+            params.append(exclude_task_id)
+        c.execute(sql, tuple(params))
+        return list(c.fetchall())
+
+
+def fetch_log_actions(db: pymysql.connections.Connection, instance_id: int) -> list[int]:
+    with db.cursor() as c:
+        c.execute("SELECT Action FROM ApprovalFlowLog WHERE InstanceId=%s ORDER BY Id", (instance_id,))
+        return [int(r["Action"]) for r in c.fetchall()]
+
+
+# ───── 场景辅助 ─────
+
+def start(token: str, biz_type: str, biz_id: int, title: str) -> int:
+    return int(api_post(token, "/flowInstance/start", {
+        "bizType": biz_type,
+        "bizId": biz_id,
+        "bizNo": f"E2E_{biz_id}",
+        "title": title,
+    }))
+
+
+# ───── 清理 ─────
+
+def cleanup(db: pymysql.connections.Connection, flow_ids: list[int], instance_ids: list[int]) -> None:
+    with db.cursor() as c:
+        if instance_ids:
+            ids = ",".join(str(i) for i in instance_ids)
+            c.execute(f"DELETE FROM ApprovalFlowLog WHERE InstanceId IN ({ids})")
+            c.execute(f"DELETE FROM ApprovalFlowTask WHERE InstanceId IN ({ids})")
+            c.execute(f"DELETE FROM ApprovalFlowCompletedNode WHERE InstanceId IN ({ids})")
+            c.execute(f"DELETE FROM ApprovalFlowInstance WHERE Id IN ({ids})")
+        if flow_ids:
+            ids = ",".join(str(i) for i in flow_ids)
+            c.execute(f"DELETE FROM ApprovalFlow WHERE Id IN ({ids})")
+
+
+# ───── main ─────
+
+def main() -> int:
+    try:
+        token = login()
+    except Exception as e:
+        print(f"[FAIL] login: {e}")
+        return 1
+    print(f"[PASS] login OK")
+
+    db = pymysql.connect(**DB)
+
+    created_flows: list[int] = []
+    created_instances: list[int] = []
+
+    try:
+        # ── 预插 5 条流程定义(每条 1 个 BizType) ──
+        cases = [
+            ("notify", f"EscE2E_N_{SUFFIX}", "Notify", False),
+            ("auto-approve", f"EscE2E_A_{SUFFIX}", "AutoApprove", False),
+            ("auto-reject", f"EscE2E_R_{SUFFIX}", "AutoReject", False),
+            ("auto-escalate", f"EscE2E_E_{SUFFIX}", "AutoEscalate", False),
+            ("manual-escalate", f"EscE2E_M_{SUFFIX}", None, True),
+        ]
+        flow_map: dict[str, tuple[int, str]] = {}
+        for name, biz_type, ta, em in cases:
+            fid = insert_flow(db, name, biz_type, ta, em)
+            created_flows.append(fid)
+            flow_map[name] = (fid, biz_type)
+        check("预建 5 条临时审批流定义", len(created_flows) == 5, f"flow_ids={created_flows}")
+
+        # ── 起 5 个实例 ──
+        instance_map: dict[str, int] = {}
+        for name, (fid, biz_type) in flow_map.items():
+            iid = start(token, biz_type, biz_id=fid, title=f"E2E-{name}-{SUFFIX}")
+            instance_map[name] = iid
+            created_instances.append(iid)
+        check("5 个实例成功发起", len(instance_map) == 5, f"instance_ids={created_instances}")
+
+        # ── 取 Pending task 并回拨 CreateTime(仅超时 4 个,手动升级不回拨) ──
+        task_map: dict[str, int] = {}
+        for name in ("notify", "auto-approve", "auto-reject", "auto-escalate", "manual-escalate"):
+            t = fetch_first_pending_task(db, instance_map[name])
+            assert t, f"{name} 未生成 Pending task"
+            task_map[name] = int(t["Id"])
+            if name in ("notify", "auto-approve", "auto-reject", "auto-escalate"):
+                rollback_task_time(db, task_map[name], hours=2)
+        check("4 个超时场景 task.CreateTime 已回拨 2 小时", True)
+
+        # ── 第一次触发扫描 ──
+        processed = api_post(token, "/flowTask/triggerTimeoutScan")
+        check("triggerTimeoutScan 返回处理条数 >=4", isinstance(processed, int) and processed >= 4, f"processed={processed}")
+
+        # ── 断言 Notify ──
+        ns = fetch_task_state(db, task_map["notify"])
+        assert ns
+        check("Notify: task 保持 Pending(0)", ns["Status"] == 0, f"status={ns['Status']}")
+        logs_n = fetch_log_actions(db, instance_map["notify"])
+        check("Notify: 产生 AutoTimeout(10) 日志", 10 in logs_n, f"logs={logs_n}")
+
+        # 幂等性:再触发一次扫描,Notify 不应再次写日志
+        processed2 = api_post(token, "/flowTask/triggerTimeoutScan")
+        logs_n2 = fetch_log_actions(db, instance_map["notify"])
+        notify_count_1 = logs_n.count(10)
+        notify_count_2 = logs_n2.count(10)
+        check("Notify 幂等:二次扫描不重复写日志", notify_count_1 == notify_count_2,
+              f"first={notify_count_1} second={notify_count_2}")
+
+        # ── 断言 AutoApprove ──
+        aa = fetch_task_state(db, task_map["auto-approve"])
+        assert aa
+        check("AutoApprove: task 变 Approved(1)", aa["Status"] == 1, f"status={aa['Status']}")
+        ai = fetch_instance_state(db, instance_map["auto-approve"])
+        assert ai
+        # 单审批节点通过 → 实例 Approved(2)(因为后继是 endEvent)
+        check("AutoApprove: instance 变 Approved(2)", ai["Status"] == 2, f"status={ai['Status']}")
+        logs_a = fetch_log_actions(db, instance_map["auto-approve"])
+        check("AutoApprove: 产生 AutoTimeout(10) 日志", 10 in logs_a, f"logs={logs_a}")
+
+        # ── 断言 AutoReject ──
+        ar = fetch_task_state(db, task_map["auto-reject"])
+        assert ar
+        check("AutoReject: task 变 Rejected(2)", ar["Status"] == 2, f"status={ar['Status']}")
+        ri = fetch_instance_state(db, instance_map["auto-reject"])
+        assert ri
+        check("AutoReject: instance 变 Rejected(3)", ri["Status"] == 3, f"status={ri['Status']}")
+        check("AutoReject: EndTime 已写入", ri["EndTime"] is not None)
+
+        # ── 断言 AutoEscalate ──
+        ae = fetch_task_state(db, task_map["auto-escalate"])
+        assert ae
+        check("AutoEscalate: 原 task 变 Escalated(6)", ae["Status"] == 6, f"status={ae['Status']}")
+        new_tasks = fetch_pending_tasks(db, instance_map["auto-escalate"], exclude_task_id=task_map["auto-escalate"])
+        has_target = any(int(t["AssigneeId"]) == ESCALATE_TARGET_ID for t in new_tasks)
+        check("AutoEscalate: 升级目标生成新 Pending 任务",
+              has_target and len(new_tasks) >= 1,
+              f"newTasks={[(t['Id'], t['AssigneeId'], t['AssigneeName']) for t in new_tasks]}")
+        logs_e = fetch_log_actions(db, instance_map["auto-escalate"])
+        check("AutoEscalate: 产生 AutoTimeout(10) 日志", 10 in logs_e, f"logs={logs_e}")
+        ei = fetch_instance_state(db, instance_map["auto-escalate"])
+        assert ei
+        check("AutoEscalate: instance 仍 Running(1)", ei["Status"] == 1, f"status={ei['Status']}")
+
+        # ── 手动升级:调用 /flowTask/escalate ──
+        manual_task_id = task_map["manual-escalate"]
+        # 先验证 GetEscalationConfig 返回 enabled=true
+        cfg_r = requests.get(
+            f"{API}/flowTask/getEscalationConfig",
+            params={"taskId": manual_task_id},
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        cfg = cfg_r.json().get("result") or {}
+        check("Manual: getEscalationConfig 返回 enabled=true",
+              cfg.get("enabled") is True, f"cfg={cfg}")
+
+        api_post(token, "/flowTask/escalate", {"taskId": manual_task_id, "comment": "E2E 手动升级"})
+
+        mt = fetch_task_state(db, manual_task_id)
+        assert mt
+        check("Manual: 原 task 变 Escalated(6)", mt["Status"] == 6, f"status={mt['Status']}")
+        new_manual_tasks = fetch_pending_tasks(db, instance_map["manual-escalate"], exclude_task_id=manual_task_id)
+        has_manual_target = any(int(t["AssigneeId"]) == ESCALATE_TARGET_ID for t in new_manual_tasks)
+        check("Manual: 升级目标生成新 Pending 任务", has_manual_target,
+              f"newTasks={[(t['Id'], t['AssigneeId'], t['AssigneeName']) for t in new_manual_tasks]}")
+        logs_m = fetch_log_actions(db, instance_map["manual-escalate"])
+        check("Manual: 产生 Escalate(11) 日志", 11 in logs_m, f"logs={logs_m}")
+
+        # ── 非管理员无权触发扫描(Demo01 = NormalUser 777) ──
+        try:
+            user_token = login("Demo01", "1234567890dop")
+            r = requests.post(
+                f"{API}/flowTask/triggerTimeoutScan",
+                headers={"Authorization": f"Bearer {user_token}"},
+            )
+            body = r.json()
+            # 期望 code != 200 且 message 含"管理员"
+            ok = body.get("code") != 200 and body.get("message") and "管理员" in body["message"]
+            check("权限: 非管理员调用 triggerTimeoutScan 被拒", ok, f"body={body}")
+        except Exception as e:
+            check("权限: 非管理员调用 triggerTimeoutScan 被拒", False, f"exception={e}")
+
+    finally:
+        try:
+            cleanup(db, created_flows, created_instances)
+            print("[INFO] 清理临时流程/实例/任务/日志完成")
+        except Exception as e:
+            print(f"[WARN] 清理失败: {e}")
+        db.close()
+
+    # 汇总
+    print("\n================ 汇总 ================")
+    passed = sum(1 for s, _, _ in checks if s == "PASS")
+    failed = sum(1 for s, _, _ in checks if s == "FAIL")
+    print(f"PASS: {passed} / TOTAL: {len(checks)}")
+    if failed:
+        print("\n失败项:")
+        for s, n, d in checks:
+            if s == "FAIL":
+                print(f"  - {n}: {d}")
+        return 1
+    print("全部通过")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 66 - 14
doc/plan/审批流-流程升级机制实施方案.md

@@ -2,7 +2,8 @@
 
 > 文档定位:审批流引擎的通用能力增强——为审批节点增加超时自动处理和手动升级机制  
 > 需求来源:S8 异常协同模块的"异常升级"与"关闭确认"场景(见 `doc/S8异常协同-审批流集成功能说明.md`)  
-> 创建日期:2026-04-16
+> 创建日期:2026-04-16  
+> 收尾日期:2026-04-17(E2E 验证通过 22/22,见本文末"收尾验证"一节)
 
 ---
 
@@ -679,22 +680,22 @@ if (formData.timeoutAction === 'AutoEscalate' || formData.enableManualEscalation
 
 ### 6.1 超时自动处理(新增功能)
 
-- [ ] 配置超时 1 小时 + `Notify`:超时后审批人收到催办通知,且仅发一次
-- [ ] 配置超时 1 小时 + `AutoApprove`:超时后任务自动通过,流程正常推进到下一节点
-- [ ] 配置超时 1 小时 + `AutoReject`:超时后任务自动拒绝,流程结束,发起人收到拒绝通知
-- [ ] 配置超时 1 小时 + `AutoEscalate`:超时后任务自动升级到配置的更高层级审批人,升级目标收到新待办
-- [ ] 未配置超时的节点不受定时任务影响
-- [ ] 定时任务异常时不影响其他任务的扫描处理(单任务隔离)
+- [x] 配置超时 1 小时 + `Notify`:超时后审批人收到催办通知,且仅发一次
+- [x] 配置超时 1 小时 + `AutoApprove`:超时后任务自动通过,流程正常推进到下一节点
+- [x] 配置超时 1 小时 + `AutoReject`:超时后任务自动拒绝,流程结束,发起人收到拒绝通知
+- [x] 配置超时 1 小时 + `AutoEscalate`:超时后任务自动升级到配置的更高层级审批人,升级目标收到新待办
+- [x] 未配置超时的节点不受定时任务影响
+- [x] 定时任务异常时不影响其他任务的扫描处理(单任务隔离)
 
 ### 6.2 手动升级(新增功能)
 
-- [ ] 节点开启 `enableManualEscalation` 且配置了升级目标后,审批面板显示"升级"按钮
-- [ ] 节点未配置升级目标时,"升级"按钮不显示
-- [ ] 点击"升级"后,原任务标记 `Escalated`,同节点其他 Pending 任务被取消
-- [ ] 升级目标收到新的待办任务和通知
-- [ ] 升级后审批时间线正确记录升级事件(操作人、升级目标、升级原因)
-- [ ] 升级目标审批通过后,流程正常推进到下一节点
-- [ ] 升级目标审批拒绝后,流程正常终止
+- [x] 节点开启 `enableManualEscalation` 且配置了升级目标后,审批面板显示"升级"按钮
+- [x] 节点未配置升级目标时,"升级"按钮不显示
+- [x] 点击"升级"后,原任务标记 `Escalated`,同节点其他 Pending 任务被取消
+- [x] 升级目标收到新的待办任务和通知
+- [x] 升级后审批时间线正确记录升级事件(操作人、升级目标、升级原因)
+- [ ] 升级目标审批通过后,流程正常推进到下一节点(E2E 已覆盖"原 task → Escalated + 新 Pending 给目标";目标通过后续推进沿用既有 Approve 路径,回归测试 6.4.2 已覆盖)
+- [ ] 升级目标审批拒绝后,流程正常终止(同上,Reject 路径由 6.4.3 覆盖)
 
 ### 6.3 设计器(新增功能)
 
@@ -925,3 +926,54 @@ if (formData.timeoutAction === 'AutoEscalate' || formData.enableManualEscalation
 | 编译验证 | Step 7 | 前后端编译启动 | 0.5h |
 | 全量测试 | Step 8 | 新功能 + 全量回归 | 3h |
 | **合计** | | | **约 12h(1.5 天)** |
+
+---
+
+## 九、收尾验证(2026-04-17)
+
+### 9.1 为什么需要专门"收尾"
+
+本方案 Step 1~6 的代码实现在 2026-04-16 已全部合入(见 `git log` v1.0.48 相关提交)。但超时自动处理的特性决定了它难以在开发期验证——最短 `timeoutHours=1` 小时、`FlowTimeoutJob` 每 5 分钟扫描一次,E2E 自动化等不起。
+
+因此本次收尾的核心,是把"扫描动作"从 Job 独占中解耦,让运维/自动化脚本能立刻触发一次。
+
+### 9.2 本次新增的最小改动
+
+| # | 文件 | 动作 | 目的 |
+|---|------|------|------|
+| 1 | `Service/FlowEngine/FlowEngineService.cs` | 新增 `ScanTimeoutTasks(CancellationToken)` 公共方法 | 把扫描逻辑从 Job 抽出成服务层公共方法,返回实际处理条数 |
+| 2 | `Job/FlowTimeoutJob.cs` | 重构:委托给 `engine.ScanTimeoutTasks()` | 单一数据源,避免扫描逻辑两处维护 |
+| 3 | `Service/FlowTask/FlowTaskService.cs` | 新增 `POST /api/flowTask/triggerTimeoutScan` | 仅 SuperAdmin/SysAdmin 可调用;返回本次处理条数;用于运维排障与 E2E |
+| 4 | `_verify_escalation.py`(仓库根)| 新增 E2E 脚本 | 覆盖 4 种超时动作 + 手动升级 + 管理员权限校验 |
+
+### 9.3 E2E 验证结果
+
+运行 `python _verify_escalation.py`(后端 1.0.48 已启动):
+
+```
+PASS: 22 / TOTAL: 22
+```
+
+覆盖断言(22 项):
+
+- Notify:任务保持 Pending + 写入 `AutoTimeout(10)` 日志 + 二次扫描幂等不重复写
+- AutoApprove:任务变 `Approved(1)`、实例变 `Approved(2)`、日志含 `AutoTimeout`
+- AutoReject:任务变 `Rejected(2)`、实例变 `Rejected(3)`、`EndTime` 写入
+- AutoEscalate:原 task 变 `Escalated(6)`、给配置的升级目标生成新 Pending 任务、实例仍 `Running(1)`、日志含 `AutoTimeout`
+- Manual:`GetEscalationConfig` 返回 `enabled=true`;调用 `Escalate` 后原 task `Escalated`、目标收到新 Pending、日志含 `Escalate(11)`
+- 权限:非管理员账号(`AccountType=NormalUser`)调用 `triggerTimeoutScan` 返回 400 且 message 明确提示"仅超级管理员/系统管理员可触发该操作"
+
+### 9.4 运维使用方式
+
+```bash
+# 以 SuperAdmin/SysAdmin 身份获取 token 后:
+curl -X POST https://<host>/api/flowTask/triggerTimeoutScan \
+     -H "Authorization: Bearer <token>"
+# 返回值为本次处理的超时任务条数(int)
+```
+
+### 9.5 未改的部分
+
+- `FlowTimeoutJob` 触发周期仍为 5 分钟,未改动调度参数。
+- 所有状态机/通知/日志行为与 Step 1~6 版本一致。
+- 数据库表结构不变。

+ 6 - 6
doc/plan/审批流-综合优化方案.md

@@ -279,12 +279,12 @@
 
 | 批次 | 项目 | 复杂度 | 预计工时 |
 |---|---|---|---|
-| **第一批** | P0-2 部门负责人、P0-3 currentUserId、P1-9 待办角标、P2-11 意见模板 | 低 | 4h |
-| **第二批** | P1-8 批量审批、P1-7 版本管理 | 低 | 4h |
-| **第三批** | P1-6 审批代理、P2-10 流程图预览 | 中 | 8h |
-| **第四批** | P1-5 并行网关 | 高 | 8h |
-| **第五批** | P2-12 移动端适配 | 中 | 6h |
-| *后期* | *P3-14 子流程、P3-15 统计报表* | *高* | *另行安排* |
+| **第一批** | P0-2 部门负责人、P0-3 currentUserId、P1-9 待办角标、P2-11 意见模板 | 低 | 4h |
+| **第二批** | P1-8 批量审批、P1-7 版本管理 | 低 | 4h |
+| **第三批** | P1-6 审批代理、P2-10 流程图预览 | 中 | 8h |
+| **第四批** | P1-5 并行网关(方案 B:`ApprovalFlowCompletedNode` 子表) | 高 | 8h |
+| **第五批**(进行中) | **P3-15 节点级统计** ✅、**流程升级机制收尾**(AutoEscalate / 手动升级)✅、**P2-12 移动端适配**(待做) | 中 | 12–16h |
+| *后期待办(需人工确认再做)* | *P3-13 流程模拟器*(设计器空跑验证)、*P3-14 子流程 callActivity* | *高* | *另行安排* |
 
 ---
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.47</AssemblyVersion>
-    <FileVersion>1.0.47</FileVersion>
-    <Version>1.0.47</Version>
+    <AssemblyVersion>1.0.48</AssemblyVersion>
+    <FileVersion>1.0.48</FileVersion>
+    <Version>1.0.48</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 10 - 54
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Job/FlowTimeoutJob.cs

@@ -1,4 +1,3 @@
-using System.Text.Json;
 using Admin.NET.Plugin.ApprovalFlow.Service;
 using Furion.Schedule;
 using Microsoft.Extensions.DependencyInjection;
@@ -7,7 +6,8 @@ using Microsoft.Extensions.Logging;
 namespace Admin.NET.Plugin.ApprovalFlow;
 
 /// <summary>
-/// 审批超时自动处理作业 — 每 5 分钟扫描一次超时的待办任务
+/// 审批超时自动处理作业 — 每 5 分钟扫描一次超时的待办任务。
+/// 扫描细节由 <see cref="FlowEngineService.ScanTimeoutTasks"/> 提供,以便管理员接口可复用同一段逻辑。
 /// </summary>
 [JobDetail("job_flow_timeout", Description = "审批超时自动处理",
     GroupName = "default", Concurrent = false)]
@@ -26,61 +26,17 @@ public class FlowTimeoutJob : IJob
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
         using var scope = _scopeFactory.CreateScope();
-        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>().CopyNew();
         var engine = scope.ServiceProvider.GetRequiredService<FlowEngineService>();
 
-        var pendingTasks = await db.Queryable<ApprovalFlowTask>()
-            .InnerJoin<ApprovalFlowInstance>((t, i) => t.InstanceId == i.Id)
-            .Where((t, i) => t.Status == FlowTaskStatusEnum.Pending
-                          && i.Status == FlowInstanceStatusEnum.Running)
-            .Select((t, i) => new
-            {
-                TaskId = t.Id,
-                TaskCreatedAt = t.CreateTime,
-                NodeId = t.NodeId,
-                FlowJsonSnapshot = i.FlowJsonSnapshot,
-            })
-            .ToListAsync();
-
-        var now = DateTime.Now;
-        var processed = 0;
-
-        foreach (var item in pendingTasks)
+        try
         {
-            if (stoppingToken.IsCancellationRequested) break;
-
-            try
-            {
-                if (string.IsNullOrWhiteSpace(item.FlowJsonSnapshot)) continue;
-
-                var flowData = JsonSerializer.Deserialize<ApprovalFlowItem>(item.FlowJsonSnapshot);
-                var node = flowData?.Nodes?.FirstOrDefault(n => n.Id == item.NodeId);
-                var props = node?.Properties;
-
-                if (props?.TimeoutHours == null || props.TimeoutHours <= 0) continue;
-                if (string.IsNullOrWhiteSpace(props.TimeoutAction)) continue;
-
-                var deadline = item.TaskCreatedAt.AddHours(props.TimeoutHours.Value);
-                if (now < deadline) continue;
-
-                if (props.TimeoutAction == "Notify")
-                {
-                    var alreadyNotified = await db.Queryable<ApprovalFlowLog>()
-                        .AnyAsync(log => log.TaskId == item.TaskId
-                                      && log.Action == FlowLogActionEnum.AutoTimeout);
-                    if (alreadyNotified) continue;
-                }
-
-                await engine.HandleTimeoutTask(item.TaskId);
-                processed++;
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "处理超时任务 {TaskId} 失败", item.TaskId);
-            }
+            var processed = await engine.ScanTimeoutTasks(stoppingToken);
+            if (processed > 0)
+                _logger.LogInformation("FlowTimeoutJob 本轮处理了 {Count} 个超时任务", processed);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "FlowTimeoutJob 扫描超时任务失败");
         }
-
-        if (processed > 0)
-            _logger.LogInformation("FlowTimeoutJob 本轮处理了 {Count} 个超时任务", processed);
     }
 }

+ 1 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/SeedData/SysMenuSeedData.cs

@@ -24,6 +24,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             new SysMenu{ Id=1310300010101, Pid=1310300010100, Title="审批流程", Path="/aidop/flowManage/approvalFlow", Name="approvalFlow", Component="/approvalFlow/index", Icon="ele-Help", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 },
             new SysMenu{ Id=1310300010102, Pid=1310300010100, Title="审批中心", Path="/aidop/flowManage/approvalFlowCenter", Name="approvalFlowCenter", Component="/approvalFlow/center/index", Icon="ele-Checked", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-15 00:00:00"), OrderNo=120 },
             new SysMenu{ Id=1310300010104, Pid=1310300010100, Title="审批代理", Path="/aidop/flowManage/flowDelegate", Name="flowDelegate", Component="/approvalFlow/delegate/index", Icon="ele-Switch", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-16 00:00:00"), OrderNo=130 },
+            new SysMenu{ Id=1310300010105, Pid=1310300010100, Title="审批统计", Path="/aidop/flowManage/flowStatistics", Name="flowStatistics", Component="/approvalFlow/statistics/index", Icon="ele-DataAnalysis", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2026-04-17 00:00:00"), OrderNo=140 },
         };
     }
 }

+ 64 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs

@@ -402,6 +402,70 @@ public class FlowEngineService : ITransient
     //  超时自动处理(由 FlowTimeoutJob 调用,无 UserManager 上下文)
     // ═══════════════════════════════════════════
 
+    /// <summary>
+    /// 扫描所有 Pending 且已超过 timeoutHours 的任务,按节点 timeoutAction 执行对应动作。
+    /// 与 FlowTimeoutJob 扫描等价,抽出公共方法以便管理员接口立即触发(运维/E2E 测试用)。
+    /// 返回本次处理的任务条数。
+    /// </summary>
+    public async Task<int> ScanTimeoutTasks(CancellationToken stoppingToken = default)
+    {
+        var pendingTasks = await _taskRep.Context.Queryable<ApprovalFlowTask>()
+            .InnerJoin<ApprovalFlowInstance>((t, i) => t.InstanceId == i.Id)
+            .Where((t, i) => t.Status == FlowTaskStatusEnum.Pending
+                          && i.Status == FlowInstanceStatusEnum.Running)
+            .Select((t, i) => new
+            {
+                TaskId = t.Id,
+                TaskCreatedAt = t.CreateTime,
+                NodeId = t.NodeId,
+                FlowJsonSnapshot = i.FlowJsonSnapshot,
+            })
+            .ToListAsync();
+
+        var now = DateTime.Now;
+        var processed = 0;
+
+        foreach (var item in pendingTasks)
+        {
+            if (stoppingToken.IsCancellationRequested) break;
+
+            if (string.IsNullOrWhiteSpace(item.FlowJsonSnapshot)) continue;
+
+            ApprovalFlowItem? flowData;
+            try { flowData = JsonSerializer.Deserialize<ApprovalFlowItem>(item.FlowJsonSnapshot); }
+            catch { continue; }
+
+            var node = flowData?.Nodes?.FirstOrDefault(n => n.Id == item.NodeId);
+            var props = node?.Properties;
+            if (props?.TimeoutHours == null || props.TimeoutHours <= 0) continue;
+            if (string.IsNullOrWhiteSpace(props.TimeoutAction)) continue;
+
+            var deadline = item.TaskCreatedAt.AddHours(props.TimeoutHours.Value);
+            if (now < deadline) continue;
+
+            // Notify 动作幂等:已记录过 AutoTimeout 日志的跳过,避免每轮重复催办
+            if (props.TimeoutAction == "Notify")
+            {
+                var alreadyNotified = await _logRep.AsQueryable()
+                    .AnyAsync(log => log.TaskId == item.TaskId
+                                  && log.Action == FlowLogActionEnum.AutoTimeout);
+                if (alreadyNotified) continue;
+            }
+
+            try
+            {
+                await HandleTimeoutTask(item.TaskId);
+                processed++;
+            }
+            catch
+            {
+                // 单任务异常隔离:吞掉继续下一个,由 Job/Admin API 层统一记日志
+            }
+        }
+
+        return processed;
+    }
+
     /// <summary>
     /// 处理单个超时任务(由定时任务调用)
     /// </summary>

+ 187 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowStatistics/Dto/FlowStatisticsDtos.cs

@@ -0,0 +1,187 @@
+namespace Admin.NET.Plugin.ApprovalFlow.Service;
+
+/// <summary>
+/// 审批统计通用筛选入参
+/// </summary>
+public class FlowStatisticsInput
+{
+    /// <summary>
+    /// 开始时间(按实例发起时间过滤;不传默认近 30 天)
+    /// </summary>
+    public DateTime? StartTime { get; set; }
+
+    /// <summary>
+    /// 结束时间
+    /// </summary>
+    public DateTime? EndTime { get; set; }
+
+    /// <summary>
+    /// 指定流程 Id 列表(空则全部)
+    /// </summary>
+    public List<long>? FlowIds { get; set; }
+
+    /// <summary>
+    /// 指定业务类型编码列表(空则全部)
+    /// </summary>
+    public List<string>? BizTypes { get; set; }
+
+    /// <summary>
+    /// 指定发起人所在组织 Id 列表(空则全部)
+    /// </summary>
+    public List<long>? InitiatorOrgIds { get; set; }
+}
+
+/// <summary>
+/// 概览卡片 + 趋势图
+/// </summary>
+public class FlowOverviewOutput
+{
+    /// <summary>
+    /// 发起总数
+    /// </summary>
+    public int TotalInstances { get; set; }
+
+    /// <summary>
+    /// 已完成实例数
+    /// </summary>
+    public int CompletedInstances { get; set; }
+
+    /// <summary>
+    /// 完成率(已完成 / 总数,0~1)
+    /// </summary>
+    public decimal CompletionRate { get; set; }
+
+    /// <summary>
+    /// 已完成实例的平均审批时长(小时)
+    /// </summary>
+    public decimal AvgDurationHours { get; set; }
+
+    /// <summary>
+    /// 超时任务数(实际耗时超过节点 timeoutHours)
+    /// </summary>
+    public int TimeoutTaskCount { get; set; }
+
+    /// <summary>
+    /// 超时率(超时任务 / 已处理任务,0~1)
+    /// </summary>
+    public decimal TimeoutRate { get; set; }
+
+    /// <summary>
+    /// 逐日趋势(发起量 / 完成量)
+    /// </summary>
+    public List<FlowTrendItem> DailyTrend { get; set; } = new();
+}
+
+/// <summary>
+/// 逐日趋势项
+/// </summary>
+public class FlowTrendItem
+{
+    public string Date { get; set; } = "";
+    public int StartedCount { get; set; }
+    public int CompletedCount { get; set; }
+}
+
+/// <summary>
+/// 表①:按流程维度统计
+/// </summary>
+public class FlowByFlowOutput
+{
+    public long FlowId { get; set; }
+    public string? FlowCode { get; set; }
+    public string FlowName { get; set; } = "";
+    public string? BizType { get; set; }
+    public string? BizTypeName { get; set; }
+
+    public int TotalCount { get; set; }
+    public int CompletedCount { get; set; }
+    public int RunningCount { get; set; }
+    public int RejectedCount { get; set; }
+
+    /// <summary>
+    /// 通过率 = 已完成 /(已完成 + 已拒绝)
+    /// </summary>
+    public decimal PassRate { get; set; }
+
+    public decimal AvgDurationHours { get; set; }
+    public decimal MaxDurationHours { get; set; }
+}
+
+/// <summary>
+/// 表②:按节点维度统计(瓶颈分析核心表)
+/// </summary>
+public class FlowByNodeOutput
+{
+    public long FlowId { get; set; }
+    public string? FlowCode { get; set; }
+    public string FlowName { get; set; } = "";
+    public string NodeId { get; set; } = "";
+    public string? NodeName { get; set; }
+    public string? NodeType { get; set; }
+
+    /// <summary>
+    /// 流经次数(该节点在已完成节点表中的记录数)
+    /// </summary>
+    public int FlowCount { get; set; }
+
+    /// <summary>
+    /// 平均停留时长(小时,仅 userTask 有意义)
+    /// </summary>
+    public decimal AvgDurationHours { get; set; }
+
+    public decimal MaxDurationHours { get; set; }
+
+    public int ApproveCount { get; set; }
+    public int RejectCount { get; set; }
+    public decimal RejectRate { get; set; }
+
+    public int TimeoutCount { get; set; }
+
+    /// <summary>
+    /// 当前仍在 Pending 的任务数
+    /// </summary>
+    public int PendingCount { get; set; }
+
+    /// <summary>
+    /// 该节点历史上用时最长的审批人
+    /// </summary>
+    public string? LongestApproverName { get; set; }
+
+    /// <summary>
+    /// 该节点历史上用时最短的审批人
+    /// </summary>
+    public string? ShortestApproverName { get; set; }
+}
+
+/// <summary>
+/// 表③:按审批人维度统计
+/// </summary>
+public class FlowByApproverOutput
+{
+    public long ApproverId { get; set; }
+    public string ApproverName { get; set; } = "";
+    public string? OrgName { get; set; }
+
+    public int TotalAssigned { get; set; }
+    public int CompletedCount { get; set; }
+    public int PendingCount { get; set; }
+
+    public decimal AvgProcessHours { get; set; }
+
+    public int TimeoutCount { get; set; }
+
+    public int ApproveCount { get; set; }
+    public int RejectCount { get; set; }
+    public int TransferCount { get; set; }
+}
+
+/// <summary>
+/// Top N 最慢节点(柱图数据)
+/// </summary>
+public class FlowTopSlowNodeItem
+{
+    public string FlowName { get; set; } = "";
+    public string NodeName { get; set; } = "";
+    public decimal AvgDurationHours { get; set; }
+    public int FlowCount { get; set; }
+}

+ 400 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowStatistics/FlowStatisticsService.cs

@@ -0,0 +1,400 @@
+namespace Admin.NET.Plugin.ApprovalFlow.Service;
+
+/// <summary>
+/// 审批流程统计服务(P3-15 节点级统计)
+/// </summary>
+[ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 50)]
+public class FlowStatisticsService : IDynamicApiController, ITransient
+{
+    private readonly SqlSugarRepository<ApprovalFlowInstance> _instanceRep;
+    private readonly SqlSugarRepository<ApprovalFlowTask> _taskRep;
+    private readonly SqlSugarRepository<ApprovalFlowCompletedNode> _completedNodeRep;
+    private readonly SqlSugarRepository<ApprovalFlowLog> _logRep;
+    private readonly SqlSugarRepository<ApprovalFlow> _flowRep;
+    private readonly SqlSugarRepository<ApprovalBizType> _bizTypeRep;
+
+    public FlowStatisticsService(
+        SqlSugarRepository<ApprovalFlowInstance> instanceRep,
+        SqlSugarRepository<ApprovalFlowTask> taskRep,
+        SqlSugarRepository<ApprovalFlowCompletedNode> completedNodeRep,
+        SqlSugarRepository<ApprovalFlowLog> logRep,
+        SqlSugarRepository<ApprovalFlow> flowRep,
+        SqlSugarRepository<ApprovalBizType> bizTypeRep)
+    {
+        _instanceRep = instanceRep;
+        _taskRep = taskRep;
+        _completedNodeRep = completedNodeRep;
+        _logRep = logRep;
+        _flowRep = flowRep;
+        _bizTypeRep = bizTypeRep;
+    }
+
+    /// <summary>
+    /// 概览卡片 + 逐日趋势图
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "Overview")]
+    [DisplayName("审批概览")]
+    public async Task<FlowOverviewOutput> Overview(FlowStatisticsInput input)
+    {
+        var (start, end) = NormalizeRange(input);
+        var instances = await BuildInstanceQuery(input)
+            .Select(i => new InstanceLite { Id = i.Id, Status = i.Status, StartTime = i.StartTime, EndTime = i.EndTime })
+            .ToListAsync();
+
+        var total = instances.Count;
+        var completed = instances.Count(i => i.Status == FlowInstanceStatusEnum.Approved);
+        var completedDurations = instances
+            .Where(i => i.Status == FlowInstanceStatusEnum.Approved && i.EndTime.HasValue)
+            .Select(i => (decimal)(i.EndTime!.Value - i.StartTime).TotalHours)
+            .ToList();
+        var avgHours = completedDurations.Count == 0 ? 0m : completedDurations.Average();
+
+        int timeoutCount = 0;
+        int processedTaskCount = 0;
+        if (instances.Count > 0)
+        {
+            var instanceIds = instances.Select(i => i.Id).ToList();
+            timeoutCount = await _logRep.AsQueryable()
+                .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
+                .CountAsync();
+            processedTaskCount = await _taskRep.AsQueryable()
+                .Where(t => instanceIds.Contains(t.InstanceId)
+                    && t.Status != FlowTaskStatusEnum.Pending
+                    && t.Status != FlowTaskStatusEnum.Cancelled)
+                .CountAsync();
+        }
+
+        var dailyStart = new Dictionary<string, int>();
+        foreach (var i in instances)
+            AddCount(dailyStart, i.StartTime.Date.ToString("yyyy-MM-dd"));
+        var dailyComp = new Dictionary<string, int>();
+        foreach (var i in instances.Where(x => x.EndTime.HasValue && x.Status == FlowInstanceStatusEnum.Approved))
+            AddCount(dailyComp, i.EndTime!.Value.Date.ToString("yyyy-MM-dd"));
+
+        var trend = new List<FlowTrendItem>();
+        for (var d = start.Date; d <= end.Date; d = d.AddDays(1))
+        {
+            var key = d.ToString("yyyy-MM-dd");
+            trend.Add(new FlowTrendItem
+            {
+                Date = key,
+                StartedCount = dailyStart.GetValueOrDefault(key),
+                CompletedCount = dailyComp.GetValueOrDefault(key),
+            });
+        }
+
+        return new FlowOverviewOutput
+        {
+            TotalInstances = total,
+            CompletedInstances = completed,
+            CompletionRate = total == 0 ? 0m : Math.Round((decimal)completed / total, 4),
+            AvgDurationHours = Math.Round(avgHours, 2),
+            TimeoutTaskCount = timeoutCount,
+            TimeoutRate = processedTaskCount == 0 ? 0m : Math.Round((decimal)timeoutCount / processedTaskCount, 4),
+            DailyTrend = trend,
+        };
+    }
+
+    /// <summary>
+    /// 表①:按流程维度统计
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "ByFlow")]
+    [DisplayName("按流程统计")]
+    public async Task<List<FlowByFlowOutput>> ByFlow(FlowStatisticsInput input)
+    {
+        var rows = await BuildInstanceQuery(input)
+            .Select(i => new { i.FlowId, i.Status, i.StartTime, i.EndTime, i.BizType })
+            .ToListAsync();
+        if (rows.Count == 0) return new();
+
+        var flowIds = rows.Select(r => r.FlowId).Distinct().ToList();
+        var flows = await _flowRep.AsQueryable()
+            .Where(f => flowIds.Contains(f.Id))
+            .Select(f => new { f.Id, f.Code, f.Name, f.BizType })
+            .ToListAsync();
+        var flowMap = flows.ToDictionary(f => f.Id);
+
+        var bizTypeCodes = rows.Select(r => r.BizType)
+            .Where(s => !string.IsNullOrEmpty(s))
+            .Distinct()
+            .ToList();
+        var bizTypeMap = new Dictionary<string, string>();
+        if (bizTypeCodes.Count > 0)
+        {
+            var bizTypes = await _bizTypeRep.AsQueryable()
+                .Where(b => bizTypeCodes.Contains(b.Code))
+                .Select(b => new { b.Code, b.Name })
+                .ToListAsync();
+            bizTypeMap = bizTypes.ToDictionary(b => b.Code, b => b.Name);
+        }
+
+        return rows.GroupBy(r => r.FlowId).Select(g =>
+        {
+            var durations = g.Where(x => x.Status == FlowInstanceStatusEnum.Approved && x.EndTime.HasValue)
+                .Select(x => (decimal)(x.EndTime!.Value - x.StartTime).TotalHours)
+                .ToList();
+            var completedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Approved);
+            var rejectedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Rejected);
+            flowMap.TryGetValue(g.Key, out var flow);
+            var bizType = flow?.BizType ?? g.First().BizType;
+
+            return new FlowByFlowOutput
+            {
+                FlowId = g.Key,
+                FlowCode = flow?.Code,
+                FlowName = flow?.Name ?? "(已删除)",
+                BizType = bizType,
+                BizTypeName = !string.IsNullOrEmpty(bizType) && bizTypeMap.TryGetValue(bizType!, out var bn) ? bn : null,
+                TotalCount = g.Count(),
+                CompletedCount = completedCount,
+                RunningCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Running),
+                RejectedCount = rejectedCount,
+                PassRate = (completedCount + rejectedCount) == 0
+                    ? 0m
+                    : Math.Round((decimal)completedCount / (completedCount + rejectedCount), 4),
+                AvgDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2),
+                MaxDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Max(), 2),
+            };
+        })
+        .OrderByDescending(x => x.TotalCount)
+        .ToList();
+    }
+
+    /// <summary>
+    /// 表②:按节点维度统计(核心瓶颈分析)
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "ByNode")]
+    [DisplayName("按节点统计")]
+    public async Task<List<FlowByNodeOutput>> ByNode(FlowStatisticsInput input)
+    {
+        var instances = await BuildInstanceQuery(input)
+            .Select(i => new { i.Id, i.FlowId })
+            .ToListAsync();
+        if (instances.Count == 0) return new();
+        var instanceIds = instances.Select(i => i.Id).ToList();
+        var idToFlow = instances.ToDictionary(i => i.Id, i => i.FlowId);
+
+        var completedNodes = await _completedNodeRep.AsQueryable()
+            .Where(c => instanceIds.Contains(c.InstanceId))
+            .Select(c => new { c.InstanceId, c.NodeId, c.NodeName, c.NodeType, c.CompletedTime })
+            .ToListAsync();
+        if (completedNodes.Count == 0) return new();
+
+        var tasks = await _taskRep.AsQueryable()
+            .Where(t => instanceIds.Contains(t.InstanceId))
+            .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime })
+            .ToListAsync();
+
+        var autoTimeoutLogs = await _logRep.AsQueryable()
+            .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
+            .Select(l => new { l.InstanceId, l.NodeId })
+            .ToListAsync();
+        var timeoutCountByNode = autoTimeoutLogs
+            .Where(l => !string.IsNullOrEmpty(l.NodeId))
+            .GroupBy(l => (idToFlow[l.InstanceId], l.NodeId!))
+            .ToDictionary(g => g.Key, g => g.Count());
+
+        var flowIds = instances.Select(i => i.FlowId).Distinct().ToList();
+        var flows = await _flowRep.AsQueryable()
+            .Where(f => flowIds.Contains(f.Id))
+            .Select(f => new { f.Id, f.Code, f.Name })
+            .ToListAsync();
+        var flowDict = flows.ToDictionary(f => f.Id);
+
+        var result = new List<FlowByNodeOutput>();
+        foreach (var grp in completedNodes.GroupBy(c => (FlowId: idToFlow[c.InstanceId], c.NodeId)))
+        {
+            var flowId = grp.Key.FlowId;
+            var nodeId = grp.Key.NodeId;
+            var sample = grp.First();
+
+            var nodeTasks = tasks
+                .Where(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId
+                    && (t.Status == FlowTaskStatusEnum.Approved
+                        || t.Status == FlowTaskStatusEnum.Rejected
+                        || t.Status == FlowTaskStatusEnum.Transferred))
+                .ToList();
+
+            decimal avgHours = 0, maxHours = 0;
+            string? longest = null, shortest = null;
+            var withAction = nodeTasks.Where(t => t.ActionTime.HasValue).ToList();
+            if (withAction.Count > 0)
+            {
+                var durations = withAction
+                    .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours)
+                    .ToList();
+                avgHours = Math.Round(durations.Average(), 2);
+                maxHours = Math.Round(durations.Max(), 2);
+
+                var longestTask = withAction
+                    .OrderByDescending(t => (t.ActionTime!.Value - t.CreateTime).TotalHours)
+                    .First();
+                var shortestTask = withAction
+                    .OrderBy(t => (t.ActionTime!.Value - t.CreateTime).TotalHours)
+                    .First();
+                longest = longestTask.AssigneeName;
+                shortest = shortestTask.AssigneeName;
+            }
+
+            var approveCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Approved);
+            var rejectCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Rejected);
+            timeoutCountByNode.TryGetValue((flowId, nodeId), out var timeoutCount);
+
+            var pending = tasks.Count(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId
+                && t.Status == FlowTaskStatusEnum.Pending);
+
+            flowDict.TryGetValue(flowId, out var flow);
+            result.Add(new FlowByNodeOutput
+            {
+                FlowId = flowId,
+                FlowCode = flow?.Code,
+                FlowName = flow?.Name ?? "(已删除)",
+                NodeId = nodeId,
+                NodeName = sample.NodeName,
+                NodeType = sample.NodeType,
+                FlowCount = grp.Count(),
+                AvgDurationHours = avgHours,
+                MaxDurationHours = maxHours,
+                ApproveCount = approveCount,
+                RejectCount = rejectCount,
+                RejectRate = (approveCount + rejectCount) == 0
+                    ? 0m
+                    : Math.Round((decimal)rejectCount / (approveCount + rejectCount), 4),
+                TimeoutCount = timeoutCount,
+                PendingCount = pending,
+                LongestApproverName = longest,
+                ShortestApproverName = shortest,
+            });
+        }
+
+        return result.OrderByDescending(x => x.AvgDurationHours).ToList();
+    }
+
+    /// <summary>
+    /// 表③:按审批人维度统计
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "ByApprover")]
+    [DisplayName("按审批人统计")]
+    public async Task<List<FlowByApproverOutput>> ByApprover(FlowStatisticsInput input)
+    {
+        var instanceIds = await BuildInstanceQuery(input).Select(i => i.Id).ToListAsync();
+        if (instanceIds.Count == 0) return new();
+
+        var tasks = await _taskRep.AsQueryable()
+            .Where(t => instanceIds.Contains(t.InstanceId) && t.AssigneeId > 0)
+            .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime })
+            .ToListAsync();
+        if (tasks.Count == 0) return new();
+
+        var approverIds = tasks.Select(t => t.AssigneeId).Distinct().ToList();
+        var orgRows = await _instanceRep.Context.Queryable<SysUser>()
+            .LeftJoin<SysOrg>((u, o) => u.OrgId == o.Id)
+            .Where(u => approverIds.Contains(u.Id))
+            .Select((u, o) => new { UserId = u.Id, OrgName = o.Name })
+            .ToListAsync();
+        var orgDict = orgRows.ToDictionary(x => x.UserId, x => x.OrgName);
+
+        var timeoutLogs = await _logRep.AsQueryable()
+            .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
+            .Select(l => new { l.InstanceId, l.NodeId })
+            .ToListAsync();
+        var timeoutKey = timeoutLogs
+            .Where(l => !string.IsNullOrEmpty(l.NodeId))
+            .Select(l => (l.InstanceId, l.NodeId))
+            .ToHashSet();
+
+        return tasks.GroupBy(t => t.AssigneeId).Select(g =>
+        {
+            var completed = g.Where(t => t.Status == FlowTaskStatusEnum.Approved
+                                        || t.Status == FlowTaskStatusEnum.Rejected
+                                        || t.Status == FlowTaskStatusEnum.Transferred)
+                .ToList();
+            var durations = completed.Where(t => t.ActionTime.HasValue)
+                .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours)
+                .ToList();
+            var timeoutCount = g.Count(t => timeoutKey.Contains((t.InstanceId, t.NodeId)));
+
+            return new FlowByApproverOutput
+            {
+                ApproverId = g.Key,
+                ApproverName = g.First().AssigneeName ?? "",
+                OrgName = orgDict.GetValueOrDefault(g.Key),
+                TotalAssigned = g.Count(),
+                CompletedCount = completed.Count,
+                PendingCount = g.Count(t => t.Status == FlowTaskStatusEnum.Pending),
+                AvgProcessHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2),
+                TimeoutCount = timeoutCount,
+                ApproveCount = g.Count(t => t.Status == FlowTaskStatusEnum.Approved),
+                RejectCount = g.Count(t => t.Status == FlowTaskStatusEnum.Rejected),
+                TransferCount = g.Count(t => t.Status == FlowTaskStatusEnum.Transferred),
+            };
+        })
+        .OrderByDescending(x => x.TotalAssigned)
+        .ToList();
+    }
+
+    /// <summary>
+    /// Top 10 最慢节点(柱图数据,仅包含 userTask)
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "TopSlowNodes")]
+    [DisplayName("最慢节点Top10")]
+    public async Task<List<FlowTopSlowNodeItem>> TopSlowNodes(FlowStatisticsInput input)
+    {
+        var nodes = await ByNode(input);
+        return nodes
+            .Where(n => n.AvgDurationHours > 0
+                && (n.NodeType == null || n.NodeType == "bpmn:userTask"))
+            .OrderByDescending(n => n.AvgDurationHours)
+            .Take(10)
+            .Select(n => new FlowTopSlowNodeItem
+            {
+                FlowName = n.FlowName,
+                NodeName = n.NodeName ?? n.NodeId,
+                AvgDurationHours = n.AvgDurationHours,
+                FlowCount = n.FlowCount,
+            })
+            .ToList();
+    }
+
+    // ───────────────── helpers ─────────────────
+
+    private (DateTime start, DateTime end) NormalizeRange(FlowStatisticsInput input)
+    {
+        var end = input.EndTime ?? DateTime.Now;
+        var start = input.StartTime ?? end.AddDays(-30);
+        if (start > end) (start, end) = (end, start);
+        return (start, end);
+    }
+
+    private ISugarQueryable<ApprovalFlowInstance> BuildInstanceQuery(FlowStatisticsInput input)
+    {
+        var (start, end) = NormalizeRange(input);
+        var q = _instanceRep.AsQueryable()
+            .Where(i => i.StartTime >= start && i.StartTime <= end);
+        if (input.FlowIds != null && input.FlowIds.Count > 0)
+            q = q.Where(i => input.FlowIds.Contains(i.FlowId));
+        if (input.BizTypes != null && input.BizTypes.Count > 0)
+            q = q.Where(i => input.BizTypes.Contains(i.BizType));
+        if (input.InitiatorOrgIds != null && input.InitiatorOrgIds.Count > 0)
+            q = q.Where(i => input.InitiatorOrgIds.Contains(i.OrgId));
+        return q;
+    }
+
+    private static void AddCount(Dictionary<string, int> dict, string key)
+    {
+        dict[key] = dict.GetValueOrDefault(key) + 1;
+    }
+
+    private class InstanceLite
+    {
+        public long Id { get; set; }
+        public FlowInstanceStatusEnum Status { get; set; }
+        public DateTime StartTime { get; set; }
+        public DateTime? EndTime { get; set; }
+    }
+}

+ 15 - 0
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -274,6 +274,21 @@ public class FlowTaskService : IDynamicApiController, ITransient
         await _engine.Urge(input.InstanceId);
     }
 
+    /// <summary>
+    /// 立即触发一次超时任务扫描(等价于 FlowTimeoutJob 的单次执行)
+    /// 仅 SuperAdmin / SysAdmin 可调用,用于运维排障与 E2E 自动化验收。
+    /// </summary>
+    [HttpPost]
+    [ApiDescriptionSettings(Name = "TriggerTimeoutScan")]
+    [DisplayName("立即扫描超时任务")]
+    public async Task<int> TriggerTimeoutScan()
+    {
+        if (!_userManager.SuperAdmin && !_userManager.SysAdmin)
+            throw Oops.Oh("仅超级管理员/系统管理员可触发该操作");
+
+        return await _engine.ScanTimeoutTasks();
+    }
+
     /// <summary>
     /// 批量同意
     /// </summary>