Просмотр исходного кода

feat(s8): 订单评审实际耗时接入数据中台链路

S8 Stage-1「订单评审」此前无任何真实耗时来源(ado_s8_order_flow_* 全为 Seed)。
唯一 LEVEL A 事件时间 aidop_action_run_log 完全在中台之外,故建立只读链路:

  aidop_action_run_log
    → mdp_stg_action_run(贴源,MdpStagingWriter canonical 契约)
    → mdp_std_action_event(标准事件层,SUCCESS/FAILED/RUNNING 全保留)
    → dwd_order_review_kpi(KPI 层,grain = tenant_id + sales_order_id)
    → S8 只读 API / 页面

KPI 采用 FIRST VALID PAIR(首条评审 SUCCESS + 首个晚于它的确认 SUCCESS),
语义冻结:后续重复评审不改写已成型 KPI;未闭环返回 NULL,不伪造 0h。
耗时按秒差换算再保留 2 位小数,避免 TIMESTAMPDIFF(HOUR) 整数截断。

租户是唯一隔离边界:三表唯一键首列均为 tenant_id,入站拒绝 tenant_id 为
NULL/0,S8 侧租户一律由 JWT 解析,DTO 不含 tenantId。实测另一租户 466 行
源数据未进入中台任何一层。

源表非内容级 append-only(RUNNING 原地 UPDATE 为终态)且无 update_time,
故在 start_time 增量之外增加 RUNNING 回捞补偿,由标准层 RUNNING 集合反向
驱动定向重拉,避免 STG 永久停留 RUNNING。

刷新回执统一为「本轮参与处理的逻辑对象数」而非数据库 affected rows:
INSERT ... ON DUPLICATE KEY UPDATE 在 MySQL 下更新计 2,直接返回会让无变化
的重复跑批虚报成处理量翻倍(实测 73/9 被报成 146/18)。改为对各层取数集合
求 COUNT(DISTINCT 业务键),与写入集合共用同一份 FROM/WHERE。

1.0.528 补 1.0.527 遗漏的菜单授权:菜单存在但未授权即路由不存在(实测 404),
授权口径继承父目录现有角色集,权限面不扩不缩。

本批不含 TargetHours / S0 PI 匹配 / 红黄绿评级 / Stage 2~5 / Stage-1 子节点。

版本:server 1.0.528 / Web 2.4.394
YY968XX 1 неделя назад
Родитель
Сommit
10309cb0ca

+ 1 - 1
Web/package.json

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

+ 86 - 0
Web/src/views/aidop/s8/api/s8OrderReviewKpiApi.ts

@@ -0,0 +1,86 @@
+// S8 Stage-1「订单评审」实际耗时 API client。
+// 数据链:aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event → dwd_order_review_kpi。
+//
+// 注意:这里【故意不带】withAidopTenantFactoryParams —— 后端一律从 JWT 解析租户、
+// 不接受前端传入的 tenantId。前端不发送该参数,租户越权在结构上不可能发生。
+import service from '/@/utils/request';
+
+const BASE = '/api/aidop/s8/order-review-kpi';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+/** 计算状态。OK 之外都没有 actualHours —— 前端必须显示"—",不得补 0。 */
+export type OrderReviewCalcStatus = 'OK' | 'IN_PROGRESS' | 'NO_START' | 'INVALID' | 'INVALID_TIME_SEQUENCE';
+
+export interface OrderReviewKpiQuery {
+	orderNo?: string;
+	calculationStatus?: string;
+	page?: number;
+	pageSize?: number;
+}
+
+export interface OrderReviewKpiRow {
+	salesOrderId: number;
+	salesOrderNo: string | null;
+	reviewStartTime: string | null;
+	reviewEndTime: string | null;
+	actualHours: number | null;
+	calculationStatus: OrderReviewCalcStatus;
+	reviewSuccessCount: number;
+	confirmSuccessCount: number;
+	reviewFailedCount: number;
+	confirmFailedCount: number;
+	reviewActionRunId: number | null;
+	confirmActionRunId: number | null;
+	calcTime: string | null;
+}
+
+export interface OrderReviewKpiList {
+	list: OrderReviewKpiRow[];
+	total: number;
+	page: number;
+	pageSize: number;
+}
+
+export interface OrderReviewKpiSummary {
+	totalOrders: number;
+	okCount: number;
+	inProgressCount: number;
+	noStartCount: number;
+	invalidCount: number;
+	invalidTimeSequenceCount: number;
+	avgActualHours: number | null;
+	minActualHours: number | null;
+	maxActualHours: number | null;
+	lastCalcTime: string | null;
+}
+
+/**
+ * 刷新回执。四个数字统一是「本轮参与处理的逻辑对象数」,
+ * **不是**新增行数、也不是数据库 affected rows(后者在 upsert 时会把更新计 2,导致虚报)。
+ */
+export interface OrderReviewKpiRefreshResult {
+	batchId: string;
+	/** 本轮实际进入贴源处理链的 source event 数 */
+	stageRows: number;
+	/** 本轮被 RUNNING 回捞补偿重拉的 distinct action_run_id 数 */
+	stageRepulledRunning: number;
+	/** 本轮参与 STD transform 的 distinct action_run_id 数 */
+	standardRows: number;
+	/** 本轮参与 KPI transform 的 distinct 销售订单数 */
+	kpiRows: number;
+}
+
+export function getOrderReviewKpiList(query: OrderReviewKpiQuery = {}) {
+	return service.get<OrderReviewKpiList>(`${BASE}/list`, { params: query }).then(unwrap);
+}
+
+export function getOrderReviewKpiSummary() {
+	return service.get<OrderReviewKpiSummary>(`${BASE}/summary`).then(unwrap);
+}
+
+export function refreshOrderReviewKpi() {
+	return service.post<OrderReviewKpiRefreshResult>(`${BASE}/refresh`).then(unwrap);
+}

+ 276 - 0
Web/src/views/aidop/s8/monitoring/OrderReviewActualHoursPage.vue

@@ -0,0 +1,276 @@
+<template>
+	<div class="order-review-hours">
+		<!-- 数据来源声明:本页不含任何 seed / mock,无数据即空态 -->
+		<el-alert type="info" :closable="false" show-icon class="lineage-tip">
+			<template #title>
+				数据来源:<b>数据中台</b> · aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event →
+				dwd_order_review_kpi。本页仅展示<b>实际耗时</b>,不含目标工时与达成率评级。
+			</template>
+		</el-alert>
+
+		<!-- 汇总卡 -->
+		<el-row :gutter="12" class="kpi-row">
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">有评审记录订单</div><div class="kpi-value">{{ summary.totalOrders }}</div></div></el-col>
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">已闭环(OK)</div><div class="kpi-value ok">{{ summary.okCount }}</div></div></el-col>
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">进行中</div><div class="kpi-value">{{ summary.inProgressCount }}</div></div></el-col>
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">平均实际耗时</div><div class="kpi-value">{{ fmtHours(summary.avgActualHours) }}</div></div></el-col>
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">最短 / 最长</div><div class="kpi-value small">{{ fmtHours(summary.minActualHours) }} / {{ fmtHours(summary.maxActualHours) }}</div></div></el-col>
+			<el-col :span="4"><div class="kpi-card"><div class="kpi-label">最近重算</div><div class="kpi-value small">{{ fmtTime(summary.lastCalcTime) }}</div></div></el-col>
+		</el-row>
+
+		<!-- 查询栏 -->
+		<el-form :inline="true" class="filter-bar" @submit.prevent>
+			<el-form-item label="订单号">
+				<el-input v-model="query.orderNo" placeholder="模糊查询" clearable style="width: 180px" />
+			</el-form-item>
+			<el-form-item label="计算状态">
+				<el-select v-model="query.calculationStatus" placeholder="全部" clearable style="width: 190px">
+					<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
+				</el-select>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+				<el-button :loading="refreshing" @click="doRefresh">重跑中台链路</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" v-loading="loading" border stripe style="width: 100%">
+			<el-table-column label="销售订单号" min-width="170" show-overflow-tooltip>
+				<template #default="{ row }">{{ row.salesOrderNo || '—' }}</template>
+			</el-table-column>
+			<el-table-column label="订单ID" width="180" show-overflow-tooltip>
+				<template #default="{ row }">{{ row.salesOrderId }}</template>
+			</el-table-column>
+			<el-table-column label="评审开始" width="170" align="center">
+				<template #default="{ row }">{{ fmtTime(row.reviewStartTime) }}</template>
+			</el-table-column>
+			<el-table-column label="评审结束" width="170" align="center">
+				<template #default="{ row }">{{ fmtTime(row.reviewEndTime) }}</template>
+			</el-table-column>
+			<el-table-column label="实际耗时(h)" width="120" align="right">
+				<template #default="{ row }">
+					<span :class="{ 'hours-value': row.actualHours !== null }">{{ fmtHours(row.actualHours) }}</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="计算状态" width="150" align="center">
+				<template #default="{ row }">
+					<el-tag :type="statusTagType(row.calculationStatus)" size="small">
+						{{ statusLabel(row.calculationStatus) }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column label="评审 成功/失败" width="130" align="center">
+				<template #default="{ row }">{{ row.reviewSuccessCount }} / {{ row.reviewFailedCount }}</template>
+			</el-table-column>
+			<el-table-column label="确认 成功/失败" width="130" align="center">
+				<template #default="{ row }">{{ row.confirmSuccessCount }} / {{ row.confirmFailedCount }}</template>
+			</el-table-column>
+			<el-table-column label="采纳事件ID(评审 / 确认)" min-width="260" show-overflow-tooltip>
+				<template #default="{ row }">{{ row.reviewActionRunId ?? '—' }} / {{ row.confirmActionRunId ?? '—' }}</template>
+			</el-table-column>
+
+			<template #empty>
+				<div class="empty-tip">
+					当前租户暂无订单评审事件数据。可点击「重跑中台链路」从数据中台重新拉取。
+				</div>
+			</template>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50, 100]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopS8OrderReviewActualHours">
+import { onMounted, reactive, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import {
+	getOrderReviewKpiList,
+	getOrderReviewKpiSummary,
+	refreshOrderReviewKpi,
+	type OrderReviewKpiRow,
+	type OrderReviewKpiSummary,
+} from '../api/s8OrderReviewKpiApi';
+
+const statusOptions = [
+	{ value: 'OK', label: '已闭环' },
+	{ value: 'IN_PROGRESS', label: '进行中' },
+	{ value: 'NO_START', label: '未开始' },
+	{ value: 'INVALID', label: '数据异常' },
+	{ value: 'INVALID_TIME_SEQUENCE', label: '时序异常' },
+];
+
+const statusLabelMap: Record<string, string> = Object.fromEntries(statusOptions.map((s) => [s.value, s.label]));
+
+const query = reactive({ orderNo: '', calculationStatus: '', page: 1, pageSize: 20 });
+const rows = ref<OrderReviewKpiRow[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const refreshing = ref(false);
+
+const emptySummary: OrderReviewKpiSummary = {
+	totalOrders: 0,
+	okCount: 0,
+	inProgressCount: 0,
+	noStartCount: 0,
+	invalidCount: 0,
+	invalidTimeSequenceCount: 0,
+	avgActualHours: null,
+	minActualHours: null,
+	maxActualHours: null,
+	lastCalcTime: null,
+};
+const summary = ref<OrderReviewKpiSummary>({ ...emptySummary });
+
+/** 未闭环订单没有耗时。显示"—",【不得】补 0 —— 0h 会被误读成"秒批"。 */
+function fmtHours(v: number | null | undefined) {
+	return v === null || v === undefined ? '—' : Number(v).toFixed(2);
+}
+
+function fmtTime(v: string | null | undefined) {
+	return v ? String(v).replace('T', ' ').slice(0, 19) : '—';
+}
+
+function statusLabel(v: string) {
+	return statusLabelMap[v] ?? v;
+}
+
+function statusTagType(v: string) {
+	if (v === 'OK') return 'success';
+	if (v === 'IN_PROGRESS') return 'warning';
+	if (v === 'NO_START') return 'info';
+	return 'danger';
+}
+
+async function loadSummary() {
+	try {
+		summary.value = (await getOrderReviewKpiSummary()) ?? { ...emptySummary };
+	} catch {
+		// 汇总失败不阻塞列表;保持空态,不回落任何假数据
+		summary.value = { ...emptySummary };
+	}
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await getOrderReviewKpiList({
+			orderNo: query.orderNo || undefined,
+			calculationStatus: query.calculationStatus || undefined,
+			page: query.page,
+			pageSize: query.pageSize,
+		});
+		rows.value = data?.list ?? [];
+		total.value = data?.total ?? 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error(e?.message || '订单评审耗时加载失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.orderNo = '';
+	query.calculationStatus = '';
+	query.page = 1;
+	loadList();
+}
+
+async function doRefresh() {
+	refreshing.value = true;
+	try {
+		const r = await refreshOrderReviewKpi();
+		// 数字为「本轮参与处理的逻辑对象数」,不是新增/变更行数 —— 无新增源事件时贴源为 0,
+		// 但标准层/KPI 仍会全量重算,故两者不为 0 属正常。
+		ElMessage.success(
+			`链路已重跑:贴源处理 ${r.stageRows}(回捞 ${r.stageRepulledRunning})· 标准事件处理 ${r.standardRows} · KPI 订单处理 ${r.kpiRows}`
+		);
+		await Promise.all([loadSummary(), loadList()]);
+	} catch (e: any) {
+		ElMessage.error(e?.message || '重跑中台链路失败');
+	} finally {
+		refreshing.value = false;
+	}
+}
+
+onMounted(() => {
+	loadSummary();
+	loadList();
+});
+</script>
+
+<style scoped lang="scss">
+.order-review-hours {
+	padding: 12px;
+}
+
+.lineage-tip {
+	margin-bottom: 12px;
+}
+
+.kpi-row {
+	margin-bottom: 12px;
+}
+
+.kpi-card {
+	background: var(--el-fill-color-light);
+	border-radius: 6px;
+	padding: 12px;
+	text-align: center;
+}
+
+.kpi-label {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+	margin-bottom: 6px;
+}
+
+.kpi-value {
+	font-size: 22px;
+	font-weight: 600;
+
+	&.ok {
+		color: var(--el-color-success);
+	}
+
+	&.small {
+		font-size: 14px;
+	}
+}
+
+.filter-bar {
+	margin-bottom: 8px;
+}
+
+.hours-value {
+	font-weight: 600;
+}
+
+.empty-tip {
+	padding: 24px 0;
+	color: var(--el-text-color-secondary);
+}
+
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 16 - 4
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
     <TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.526</AssemblyVersion>
-    <FileVersion>1.0.526</FileVersion>
-    <Version>1.0.526</Version>
+    <AssemblyVersion>1.0.528</AssemblyVersion>
+    <FileVersion>1.0.528</FileVersion>
+    <Version>1.0.528</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -793,6 +793,18 @@
     <None Update="UpdateScripts\1.0.525.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.527.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.527.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.528.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.528.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 153 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.527.sql

@@ -0,0 +1,153 @@
+-- =====================================================================================
+-- 1.0.527 · S8 Stage-1「订单评审」ActualHours 数据链(Action Event → MDP → KPI)
+--
+-- ── 背景 ────────────────────────────────────────────────────────────────────────────
+-- S8 Stage-1 的「订单评审实际耗时」此前无任何真实数据来源:
+--   · mdp_std_so 只投影评审【状态】(progress/flow_state/review_status),零评审【时间】列;
+--   · crm_seorderentry.update_time 被 6 类无关业务共享覆盖(评审/确认/重排/工单调整/订单编辑),
+--     判定为 LEVEL C,不可作 KPI Authority;
+--   · 系统无 progress 状态迁移历史表。
+-- 唯一 LEVEL A 事件时间是 aidop_action_run_log,但它完全未进入数据中台
+--   (mdp_entity 注册 0 / mdp_stg_* 引用 0 / ETL 代码引用 0)。
+--
+-- ── 本批建立的链路 ──────────────────────────────────────────────────────────────────
+--   aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event → dwd_order_review_kpi → S8
+-- 与中台既有唯一的「源侧事件流水」先例 mdp_stg_inv_trans → mdp_std_inv_trans 同构。
+--
+-- ── 关键约束 ────────────────────────────────────────────────────────────────────────
+-- ① tenant_id 是唯一隔离边界,三张表的唯一键首列均为 tenant_id。
+-- ② 源行【不是内容级 append-only】:FinishAsync/UpdateProgressAsync/FailStaleRunningAsync
+--    会原地 UPDATE 既有行(RUNNING → SUCCESS/FAILED)。因此同步必须幂等 upsert,
+--    且必须有 RUNNING 回捞补偿(源表无 update_time 列,create_time ≡ start_time 且不随更新推进)。
+-- ③ 源 id 是雪花(YitIdHelper)不是自增 —— 全局唯一但非严格单调,只作 tiebreaker / 唯一键。
+-- ④ STD 是审计事实层:SUCCESS / FAILED / RUNNING 全部保留,KPI 层再过滤。
+--
+-- ── 本批不做 ────────────────────────────────────────────────────────────────────────
+-- TargetHours / S0 PI 匹配 / order_type 映射 / factory 推导 / 红黄绿 / Stage 2~5 / 子节点。
+-- 不改 mdp_std_so,不改任何源业务表。
+-- =====================================================================================
+
+-- ---------------------------------------------------------------------------
+-- ① L1 贴源层:mdp_stg_action_run
+--    一行 = 源 aidop_action_run_log 某 action_run_id 的【最新快照】
+--    列集必须满足 MdpStagingWriter 的 canonical 契约(见 MdpStagingWriter.cs:15-19)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `mdp_stg_action_run` (
+  `id`              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '代理主键',
+  `tenant_id`       BIGINT       NOT NULL                COMMENT '租户ID(唯一隔离边界,uk 首列)',
+  `source_system`   VARCHAR(50)  NOT NULL                COMMENT '源系统编码,固定 AIDOPDEV_MYSQL',
+  `source_table`    VARCHAR(100) NOT NULL                COMMENT '源表名,固定 aidop_action_run_log',
+  `source_row_id`   VARCHAR(100) NOT NULL                COMMENT '源行主键 = aidop_action_run_log.id',
+  `source_biz_key`  VARCHAR(200) NOT NULL                COMMENT '业务键 = 源 id(biz_key_expr=id)',
+  `raw_data`        JSON         NOT NULL                COMMENT '源行原文 JSON',
+  `sync_batch_id`   VARCHAR(100) NOT NULL                COMMENT '同步批次号',
+  `sync_time`       DATETIME     NOT NULL                COMMENT '本次同步时刻',
+  `process_status`  VARCHAR(20)  NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/DONE',
+  `process_message` VARCHAR(500)     NULL                COMMENT '处理信息',
+  `create_time`     DATETIME         NULL                COMMENT '首次落库时刻',
+  `update_time`     DATETIME         NULL                COMMENT '最近一次 upsert 时刻',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_stg_action_run` (`tenant_id`, `source_table`, `source_biz_key`),
+  KEY `idx_stg_action_run_rowid` (`tenant_id`, `source_row_id`),
+  KEY `idx_stg_action_run_batch` (`tenant_id`, `sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MDP贴源-业务动作运行日志';
+
+-- ---------------------------------------------------------------------------
+-- ② L2 标准事件层:mdp_std_action_event
+--    Grain = tenant_id + action_run_id
+--    审计事实层:FAILED / RUNNING / SUCCESS 全保留,不因 KPI 只需 SUCCESS 而丢弃
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `mdp_std_action_event` (
+  `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '代理主键',
+  `tenant_id`         BIGINT        NOT NULL                COMMENT '租户ID(唯一隔离边界,uk 首列)',
+  `action_run_id`     BIGINT        NOT NULL                COMMENT '动作运行ID = 源 aidop_action_run_log.id(雪花)',
+  `action_code`       VARCHAR(80)   NOT NULL                COMMENT '动作编码,如 S1_ORDER_REVIEW',
+  `biz_type`          VARCHAR(80)   NOT NULL                COMMENT '业务类型,如 crm_seorder',
+  `biz_id`            BIGINT            NULL                COMMENT '业务主键(订单头 crm_seorder.Id)',
+  `biz_no`            VARCHAR(128)      NULL                COMMENT '业务单号(仅展示/交叉校验,不作归因权威)',
+  `action_status`     VARCHAR(20)   NOT NULL                COMMENT 'RUNNING / SUCCESS / FAILED',
+  `action_start_time` DATETIME      NOT NULL                COMMENT '动作开始执行时刻',
+  `action_end_time`   DATETIME          NULL                COMMENT '动作完成时刻(RUNNING 时为 NULL)',
+  `message`           VARCHAR(1000)     NULL                COMMENT '动作消息',
+  `detail_json`       JSON              NULL                COMMENT '动作明细快照',
+  `source_system`     VARCHAR(50)   NOT NULL                COMMENT '源系统编码',
+  `source_table`      VARCHAR(100)  NOT NULL                COMMENT '源表名',
+  `source_biz_key`    VARCHAR(200)  NOT NULL                COMMENT '贴源业务键',
+  `sync_batch_id`     VARCHAR(100)      NULL                COMMENT '最近一次物化批次号',
+  `create_time`       DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次物化时刻',
+  `update_time`       DATETIME          NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '最近一次物化时刻',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_std_action_event` (`tenant_id`, `action_run_id`),
+  KEY `idx_std_action_event_biz` (`tenant_id`, `biz_type`, `biz_id`, `action_code`, `action_status`),
+  KEY `idx_std_action_event_start` (`tenant_id`, `action_code`, `action_start_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MDP标准层-业务动作事件(审计事实,全状态保留)';
+
+-- ---------------------------------------------------------------------------
+-- ③ L3 KPI 事实层:dwd_order_review_kpi
+--    Grain = tenant_id + sales_order_id(一张销售订单一条当前有效 KPI)
+--    本批不含 target_hours / kpi_status / factory_ref_id / order_type
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `dwd_order_review_kpi` (
+  `id`                     BIGINT        NOT NULL AUTO_INCREMENT COMMENT '代理主键',
+  `tenant_id`              BIGINT        NOT NULL                COMMENT '租户ID(唯一隔离边界,uk 首列)',
+  `sales_order_id`         BIGINT        NOT NULL                COMMENT '销售订单头ID = crm_seorder.Id(归因权威)',
+  `sales_order_no`         VARCHAR(128)      NULL                COMMENT '销售订单号(展示用,来自 biz_no)',
+  `review_action_run_id`   BIGINT            NULL                COMMENT '被采纳的评审事件ID',
+  `confirm_action_run_id`  BIGINT            NULL                COMMENT '被采纳的交期确认事件ID',
+  `review_start_time`      DATETIME          NULL                COMMENT '评审开始时刻 = FIRST SUCCESS 的 action_start_time',
+  `review_end_time`        DATETIME          NULL                COMMENT '评审结束时刻 = 首个晚于开始的确认 SUCCESS 的 action_end_time',
+  `actual_hours`           DECIMAL(12,2)     NULL                COMMENT '实际耗时(自然 elapsed 小时,2 位小数)',
+  `calculation_status`     VARCHAR(32)   NOT NULL                COMMENT 'OK/NO_START/IN_PROGRESS/INVALID/INVALID_TIME_SEQUENCE',
+  `review_success_count`   INT           NOT NULL DEFAULT 0      COMMENT '评审 SUCCESS 次数(可观测性,不参与计算)',
+  `confirm_success_count`  INT           NOT NULL DEFAULT 0      COMMENT '确认 SUCCESS 次数(可观测性,不参与计算)',
+  `review_failed_count`    INT           NOT NULL DEFAULT 0      COMMENT '评审 FAILED 次数(可观测性,不参与计算)',
+  `confirm_failed_count`   INT           NOT NULL DEFAULT 0      COMMENT '确认 FAILED 次数(可观测性,不参与计算)',
+  `calc_batch_id`          VARCHAR(100)      NULL                COMMENT '最近一次重算批次号',
+  `calc_time`              DATETIME          NULL                COMMENT '最近一次重算时刻',
+  `create_time`            DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次落库时刻',
+  `update_time`            DATETIME          NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '最近一次更新时刻',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_dwd_order_review_kpi` (`tenant_id`, `sales_order_id`),
+  KEY `idx_dwd_order_review_kpi_status` (`tenant_id`, `calculation_status`),
+  KEY `idx_dwd_order_review_kpi_no` (`tenant_id`, `sales_order_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DWD-订单评审KPI(本批仅 ActualHours,无 TargetHours)';
+
+-- ---------------------------------------------------------------------------
+-- ④ MDP 实体注册:S1_ACTION_RUN_LOG
+--    source_id = 1 (AIDOPDEV_MYSQL,本库);sync_mode=INCR;主游标 start_time + id tiebreaker
+--    幂等:按 entity_code 判存在
+--    注意:incr_column 仅为主游标;RUNNING 终态回捞由 ActionEventMdpSyncService 补偿,
+--          原因是源表无 update_time、create_time ≡ start_time 且不随 UPDATE 推进。
+-- ---------------------------------------------------------------------------
+INSERT INTO `mdp_entity`
+  (`tenant_id`, `source_id`, `entity_code`, `entity_name`, `entity_type`,
+   `source_table_name`, `target_table_name`, `sync_mode`, `incr_column`,
+   `batch_size`, `biz_key_expr`, `status`, `remark`, `create_time`, `update_time`)
+SELECT 0, 1, 'S1_ACTION_RUN_LOG', 'S1业务动作运行日志', 'TABLE',
+       'aidop_action_run_log', 'mdp_stg_action_run', 'INCR', 'start_time',
+       2000, 'id', 1,
+       'S8 Stage-1 订单评审 ActualHours 的唯一时间 Authority;RUNNING 终态由补偿查询回捞',
+       NOW(), NOW()
+WHERE NOT EXISTS (
+  SELECT 1 FROM (SELECT `entity_code` FROM `mdp_entity`) t
+  WHERE t.`entity_code` = 'S1_ACTION_RUN_LOG'
+);
+
+-- ---------------------------------------------------------------------------
+-- ⑤ 菜单:S8 异常监控 → 订单评审耗时
+--    注意:Admin.NET 框架表 EnableUnderLine=false → 表名/列名 PascalCase(SysMenu,非 sys_menu)。
+--    与 SysMenuSeedData.cs 的 s8MonitoringDirId + 7 = 1329008000027 同号(既有库不跑 Seed,故此处补)。
+--    幂等:按 Id 判存在。
+-- ---------------------------------------------------------------------------
+INSERT INTO `SysMenu`
+  (`Id`, `Pid`, `Type`, `Name`, `Path`, `Component`, `Title`, `Icon`,
+   `IsIframe`, `OutLink`, `IsHide`, `IsKeepAlive`, `IsAffix`, `OrderNo`, `Status`, `Remark`, `CreateTime`)
+SELECT 1329008000027, 1329008000020, 2, 'aidopS8OrderReviewActualHours',
+       '/aidop/s8/monitoring/order-review-hours',
+       '/aidop/s8/monitoring/OrderReviewActualHoursPage',
+       '订单评审耗时', 'ele-Timer',
+       0, 0, 0, 1, 0, 16, 1,
+       'S8 Stage-1 订单评审实际耗时(数据中台 dwd_order_review_kpi,无 seed 回落)', NOW()
+WHERE NOT EXISTS (
+  SELECT 1 FROM (SELECT `Id` FROM `SysMenu`) t WHERE t.`Id` = 1329008000027
+);

+ 59 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.527.verify.sql

@@ -0,0 +1,59 @@
+-- 1.0.527.verify.sql — 每条 SELECT 都必须返回 1
+
+-- ── ① 三张表已创建 ──────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('mdp_stg_action_run','mdp_std_action_event','dwd_order_review_kpi')) = 3;
+
+-- ── ② tenant_id 必须是三张表唯一键的第一列(Principle 1 的结构性保证)──────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `NON_UNIQUE` = 0
+           AND `SEQ_IN_INDEX` = 1
+           AND `COLUMN_NAME` = 'tenant_id'
+           AND `INDEX_NAME` IN ('uk_stg_action_run','uk_std_action_event','uk_dwd_order_review_kpi')) = 3;
+
+-- ── ③ STG 满足 MdpStagingWriter canonical 契约(缺一列即写入期抛异常)───────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_stg_action_run'
+           AND `COLUMN_NAME` IN ('tenant_id','source_system','source_table','source_row_id',
+                                 'source_biz_key','raw_data','sync_batch_id','sync_time',
+                                 'process_status','create_time','update_time')) = 11;
+
+-- ── ④ STD 事件层关键列齐备(审计事实层:状态 + 起止时间 + 归因键)──────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_action_event'
+           AND `COLUMN_NAME` IN ('tenant_id','action_run_id','action_code','biz_type','biz_id',
+                                 'biz_no','action_status','action_start_time','action_end_time',
+                                 'message','detail_json')) = 11;
+
+-- ── ⑤ DWD KPI 关键列齐备,且本批【不得】出现 TargetHours / kpi_status ──────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_order_review_kpi'
+           AND `COLUMN_NAME` IN ('tenant_id','sales_order_id','sales_order_no',
+                                 'review_start_time','review_end_time','actual_hours',
+                                 'calculation_status')) = 7;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_order_review_kpi'
+           AND `COLUMN_NAME` IN ('target_hours','kpi_status','factory_ref_id','order_type')) = 0;
+
+-- ── ⑥ MDP 实体已注册且配置正确(本库源 + INCR + 主游标 + biz_key)──────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_entity`
+         WHERE `entity_code` = 'S1_ACTION_RUN_LOG'
+           AND `source_id` = 1
+           AND `source_table_name` = 'aidop_action_run_log'
+           AND `target_table_name` = 'mdp_stg_action_run'
+           AND `sync_mode` = 'INCR'
+           AND `incr_column` = 'start_time'
+           AND `biz_key_expr` = 'id'
+           AND `status` = 1) = 1;
+
+-- 实体注册必须唯一,重复执行迁移不得产生第二行
+SELECT (SELECT COUNT(*) FROM `mdp_entity` WHERE `entity_code` = 'S1_ACTION_RUN_LOG') = 1;
+
+-- ── ⑦ Snapshot Integrity:本批不得改造 mdp_std_so ───────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_so'
+           AND `COLUMN_NAME` IN ('review_start_time','review_end_time','confirm_time',
+                                 'status_change_time','actual_hours')) = 0;

+ 25 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.528.sql

@@ -0,0 +1,25 @@
+-- =====================================================================================
+-- 1.0.528 · 补 1.0.527 的菜单授权(S8「订单评审耗时」)
+--
+-- 背景:1.0.527 只 INSERT 了 SysMenu 行,未写 SysRoleMenu。
+-- Admin.NET 的前端路由由「当前用户已授权菜单」动态构建 —— 菜单存在但未授权 =
+-- 菜单树里没有 = 路由未注册 = 直接访问 404。冒烟时实测复现。
+--
+-- 授权口径:**不硬编码角色清单**,而是继承父目录 S8 异常监控(1329008000020) 的现有
+-- 授权集合。这样新页面的可见范围与同目录既有页面完全一致,不扩大也不缩小权限面。
+--
+-- 幂等:按 (RoleId, MenuId) 判存在;Id 用 1329008000027000 + 行号(已验证该区段无冲突)。
+-- 注意:Admin.NET 框架表 EnableUnderLine=false → PascalCase(SysRoleMenu / SysMenu)。
+-- =====================================================================================
+
+INSERT INTO `SysRoleMenu` (`Id`, `RoleId`, `MenuId`)
+SELECT 1329008000027000 + ROW_NUMBER() OVER (ORDER BY src.RoleId), src.RoleId, 1329008000027
+FROM (
+  SELECT DISTINCT rm.`RoleId`
+    FROM `SysRoleMenu` rm
+   WHERE rm.`MenuId` = 1329008000020
+     AND NOT EXISTS (
+           SELECT 1 FROM (SELECT `RoleId` FROM `SysRoleMenu` WHERE `MenuId` = 1329008000027) t
+            WHERE t.`RoleId` = rm.`RoleId`
+         )
+) src;

+ 23 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.528.verify.sql

@@ -0,0 +1,23 @@
+-- 1.0.528.verify.sql — 每条 SELECT 都必须返回 1
+
+-- ① 新菜单的授权角色集 == 父目录(S8 异常监控)的授权角色集,一个不多一个不少
+SELECT (SELECT COUNT(*) FROM (
+          SELECT `RoleId` FROM `SysRoleMenu` WHERE `MenuId` = 1329008000020
+          UNION ALL
+          SELECT `RoleId` FROM `SysRoleMenu` WHERE `MenuId` = 1329008000027
+        ) u GROUP BY `RoleId` HAVING COUNT(*) <> 2) IS NULL;
+
+-- ② 已实际授权(非 0 行)
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu` WHERE `MenuId` = 1329008000027) > 0;
+
+-- ③ 无重复授权(幂等:重复执行不产生第二份)
+SELECT (SELECT COUNT(*) FROM (
+          SELECT `RoleId` FROM `SysRoleMenu` WHERE `MenuId` = 1329008000027
+           GROUP BY `RoleId` HAVING COUNT(*) > 1) d) = 0;
+
+-- ④ 未越界授权到父目录之外的角色
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu` a
+         WHERE a.`MenuId` = 1329008000027
+           AND NOT EXISTS (SELECT 1 FROM (SELECT `RoleId` FROM `SysRoleMenu`
+                                           WHERE `MenuId` = 1329008000020) b
+                            WHERE b.`RoleId` = a.`RoleId`)) = 0;

+ 43 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderReviewKpiController.cs

@@ -0,0 +1,43 @@
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// S8 Stage-1「订单评审」实际耗时 API。
+/// 数据链:<c>aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event → dwd_order_review_kpi</c>。
+/// 本控制器<b>只消费 DWD</b>,不直读任何源业务表;租户从 JWT 解析,不接受前端 tenantId。
+/// 路由:<c>api/aidop/s8/order-review-kpi/*</c>。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/order-review-kpi")]
+[NonUnify]
+public class AdoS8OrderReviewKpiController : ControllerBase
+{
+    private readonly S8OrderReviewKpiService _svc;
+
+    public AdoS8OrderReviewKpiController(S8OrderReviewKpiService svc)
+    {
+        _svc = svc;
+    }
+
+    /// <summary>订单评审耗时列表(分页)。</summary>
+    [HttpGet("list")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
+    public async Task<IActionResult> ListAsync([FromQuery] AdoS8OrderReviewKpiQueryDto query)
+        => Ok(await _svc.GetListAsync(query));
+
+    /// <summary>订单评审耗时汇总(本批无 TargetHours / 红黄绿)。</summary>
+    [HttpGet("summary")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
+    public async Task<IActionResult> SummaryAsync()
+        => Ok(await _svc.GetSummaryAsync());
+
+    /// <summary>手动重跑中台链路(幂等)。</summary>
+    [HttpPost("refresh")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
+    public async Task<IActionResult> RefreshAsync(CancellationToken cancellationToken)
+        => Ok(await _svc.RefreshAsync(cancellationToken));
+}

+ 94 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8OrderReviewKpiDtos.cs

@@ -0,0 +1,94 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+
+/// <summary>S8 Stage-1「订单评审」耗时列表查询条件。注意:不含 tenantId —— 租户只从 JWT 解析。</summary>
+public class AdoS8OrderReviewKpiQueryDto
+{
+    /// <summary>订单号模糊过滤。</summary>
+    public string? OrderNo { get; set; }
+
+    /// <summary>按计算状态过滤:OK / IN_PROGRESS / NO_START / INVALID / INVALID_TIME_SEQUENCE。</summary>
+    public string? CalculationStatus { get; set; }
+
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+/// <summary>单张订单的 Stage-1 评审耗时事实(全部来自 dwd_order_review_kpi)。</summary>
+public class AdoS8OrderReviewKpiRowDto
+{
+    /// <summary>销售订单头ID(crm_seorder.Id),归因权威。</summary>
+    public long SalesOrderId { get; set; }
+
+    /// <summary>销售订单号(展示用)。</summary>
+    public string? SalesOrderNo { get; set; }
+
+    /// <summary>评审开始时刻。</summary>
+    public DateTime? ReviewStartTime { get; set; }
+
+    /// <summary>评审结束时刻。</summary>
+    public DateTime? ReviewEndTime { get; set; }
+
+    /// <summary>实际耗时(自然 elapsed 小时,2 位小数)。未闭环时为 null —— 不伪造 0。</summary>
+    public decimal? ActualHours { get; set; }
+
+    /// <summary>OK / IN_PROGRESS / NO_START / INVALID / INVALID_TIME_SEQUENCE。</summary>
+    public string CalculationStatus { get; set; } = "";
+
+    public int ReviewSuccessCount { get; set; }
+    public int ConfirmSuccessCount { get; set; }
+    public int ReviewFailedCount { get; set; }
+    public int ConfirmFailedCount { get; set; }
+
+    /// <summary>被采纳的评审事件ID(可追溯到 mdp_std_action_event)。</summary>
+    public long? ReviewActionRunId { get; set; }
+
+    /// <summary>被采纳的交期确认事件ID。</summary>
+    public long? ConfirmActionRunId { get; set; }
+
+    /// <summary>最近一次重算时刻。</summary>
+    public DateTime? CalcTime { get; set; }
+}
+
+/// <summary>分页结果。</summary>
+public class AdoS8OrderReviewKpiListDto
+{
+    public List<AdoS8OrderReviewKpiRowDto> List { get; set; } = [];
+    public int Total { get; set; }
+    public int Page { get; set; }
+    public int PageSize { get; set; }
+}
+
+/// <summary>Stage-1 评审耗时汇总。本批不含 TargetHours / 达成率 / 红黄绿。</summary>
+public class AdoS8OrderReviewKpiSummaryDto
+{
+    /// <summary>有评审动作记录的订单总数。</summary>
+    public int TotalOrders { get; set; }
+
+    /// <summary>已闭环(OK)订单数 —— 只有这些有 ActualHours。</summary>
+    public int OkCount { get; set; }
+
+    public int InProgressCount { get; set; }
+    public int NoStartCount { get; set; }
+    public int InvalidCount { get; set; }
+    public int InvalidTimeSequenceCount { get; set; }
+
+    /// <summary>OK 订单的平均实际耗时(小时)。OkCount=0 时为 null,不返回 0。</summary>
+    public decimal? AvgActualHours { get; set; }
+
+    /// <summary>OK 订单的最短 / 最长实际耗时(小时)。</summary>
+    public decimal? MinActualHours { get; set; }
+    public decimal? MaxActualHours { get; set; }
+
+    /// <summary>KPI 表最近一次重算时刻。null 表示该租户从未跑过链路。</summary>
+    public DateTime? LastCalcTime { get; set; }
+}
+
+/// <summary>手动刷新链路的返回。</summary>
+public class AdoS8OrderReviewKpiRefreshResultDto
+{
+    public string BatchId { get; set; } = "";
+    public int StageRows { get; set; }
+    public int StageRepulledRunning { get; set; }
+    public int StandardRows { get; set; }
+    public int KpiRows { get; set; }
+}

+ 441 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ActionEventMdpSyncService.cs

@@ -0,0 +1,441 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// S8 Stage-1「订单评审」ActualHours 数据链:
+/// <c>aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event → dwd_order_review_kpi</c>。
+///
+/// <para><b>为什么必须经中台</b>:S8 正式运行时禁止直读 <c>aidop_action_run_log</c> /
+/// <c>crm_seorder</c> / <c>crm_seorderentry</c>。配对、FAILED 过滤、重复动作处理全部在本服务完成,
+/// S8 只消费 <c>dwd_order_review_kpi</c>。</para>
+///
+/// <para><b>租户是唯一隔离边界</b>:每一层的读、写、JOIN、唯一键首列都带 <c>tenant_id</c>;
+/// 源侧 <c>tenant_id</c> 为 NULL 或 0 一律拒绝入站,绝不补默认租户。</para>
+/// </summary>
+public sealed class ActionEventMdpSyncService : ITransient
+{
+    /// <summary>MDP 实体码(注册见 UpdateScripts/1.0.527.sql)。</summary>
+    public const string EntityCode = "S1_ACTION_RUN_LOG";
+
+    private const string SourceTable = "aidop_action_run_log";
+    private const string ReviewActionCode = "S1_ORDER_REVIEW";
+    private const string ConfirmActionCode = "S1_DELIVERY_CONFIRM";
+    private const string OrderBizType = "crm_seorder";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpModuleStagingPuller _stagingPuller;
+    private readonly ILogger<ActionEventMdpSyncService> _logger;
+
+    public ActionEventMdpSyncService(
+        ISqlSugarClient db,
+        MdpModuleStagingPuller stagingPuller,
+        ILogger<ActionEventMdpSyncService> logger)
+    {
+        _db = db;
+        _stagingPuller = stagingPuller;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 全链路跑批:源→STG(增量 + RUNNING 回捞)→ STD → DWD KPI。
+    /// 幂等:同一份源数据重复执行,STD 与 DWD 结果完全一致。
+    /// </summary>
+    public async Task<ActionEventSyncResult> RunAsync(long tenantId, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0)
+            throw new InvalidOperationException("ActionEventMdpSyncService 必须显式传入有效 tenantId,禁止默认租户。");
+
+        var now = DateTime.Now;
+        var batchId = $"S1_ACTION_EVENT_{tenantId}_{now:yyyyMMddHHmmss}";
+        var result = new ActionEventSyncResult { BatchId = batchId, TenantId = tenantId };
+
+        result.StageRows = await PullStagingAsync(tenantId, batchId, cancellationToken);
+        result.StageRepulledRunning = await RepullRunningAsync(tenantId, batchId, now, cancellationToken);
+        result.StandardRows = await TransformStdFromStgAsync(tenantId, batchId, cancellationToken);
+        result.KpiRows = await BuildOrderReviewKpiAsync(tenantId, batchId, now, cancellationToken);
+
+        _logger.LogInformation(
+            "S1 动作事件链路完成 tenant={TenantId} batch={BatchId} stg={Stg} repull={Repull} std={Std} kpi={Kpi}",
+            tenantId, batchId, result.StageRows, result.StageRepulledRunning, result.StandardRows, result.KpiRows);
+
+        return result;
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // PHASE 2 · 源 → STG
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 主增量:走统一执行器(<c>sync_mode=INCR</c>,主游标 <c>start_time</c>,水位存
+    /// <c>mdp_entity.last_cursor</c>)。写入经 <see cref="MdpStagingWriter"/> 的 canonical 契约,
+    /// 其中已内置「tenant 解析不出即跳过 + 跨租户行丢弃」双重守卫。
+    /// </summary>
+    private Task<int> PullStagingAsync(long tenantId, string batchId, CancellationToken cancellationToken) =>
+        _stagingPuller.PullEntitiesAsync(
+            [EntityCode], batchId, tenantId,
+            fullRefresh: false,
+            taskCode: "S1_ACTION_EVENT_INBOUND",
+            cancellationToken,
+            factoryId: 0,
+            requireMatchingSourceTenant: true);
+
+    /// <summary>
+    /// <b>RUNNING 回捞补偿(本批的核心风险控制点)。</b>
+    ///
+    /// <para>源行不是内容级 append-only:<c>StartAsync</c> 先 INSERT 一行
+    /// <c>status='RUNNING', end_time=NULL</c>,之后 <c>FinishAsync</c> /
+    /// <c>FailStaleRunningAsync</c> 会<b>原地 UPDATE</b> 成 SUCCESS/FAILED 并补 <c>end_time</c>。
+    /// 而源表<b>没有 update_time 列</b>,且 <c>create_time ≡ start_time</c>、不随 UPDATE 推进
+    /// —— 所以主游标 <c>start_time &gt; @cursor</c> 永远不会第二次捞到这一行,
+    /// 若无补偿,STG/STD 会永久停留在 RUNNING。</para>
+    ///
+    /// <para>补偿判据:以<b>标准层当前仍为 RUNNING 的 action_run_id 集合</b>反向驱动重拉。
+    /// 该集合天然极小(实测全库仅 1 行),因此:
+    /// ① tenant-safe —— 读写两侧都带 <c>tenant_id=@TenantId</c>;
+    /// ② 幂等 —— <c>ON DUPLICATE KEY UPDATE</c>,重复执行不产生新行;
+    /// ③ 不全表扫描 —— 由 RUNNING 集合驱动,走 <c>uk_std_action_event</c> 与源主键;
+    /// ④ 不依赖 id 单调性 —— 按 id 精确等值匹配,与雪花乱序无关;
+    /// ⑤ 不遗漏迟到终态 —— 只要某行在 STD 还是 RUNNING,每轮都会被重新检查。</para>
+    ///
+    /// <para>此处不经 <see cref="MdpStagingWriter"/> 而用定向 <c>INSERT…SELECT</c>,
+    /// 原因是框架执行器只支持「按水位翻页」、不支持「按 id 集合定向重拉」;
+    /// 因此租户守卫在本 SQL 内显式重建(<c>tenant_id=@TenantId</c> +
+    /// <c>tenant_id IS NOT NULL</c> + <c>tenant_id&lt;&gt;0</c>),语义与框架守卫一致。</para>
+    /// </summary>
+    private async Task<int> RepullRunningAsync(
+        long tenantId, string batchId, DateTime now, CancellationToken cancellationToken)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var ps = new SugarParameter[]
+        {
+            new("@TenantId", tenantId),
+            new("@BatchId", batchId),
+            new("@Now", now)
+        };
+
+        // 逻辑计数:本轮被回捞的 distinct action_run_id 数。
+        // 必须先于 upsert 求值(upsert 不改变该集合,但顺序固定更易解释),
+        // 且与下方 INSERT 共用同一份 WHERE(RepullRunningFrom),杜绝谓词漂移。
+        var logicalRows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(DISTINCT s.`id`) {RepullRunningFrom}", ps);
+
+        const string sql = $"""
+            INSERT INTO `mdp_stg_action_run`
+              (`tenant_id`, `source_system`, `source_table`, `source_row_id`, `source_biz_key`,
+               `raw_data`, `sync_batch_id`, `sync_time`, `process_status`, `create_time`, `update_time`)
+            SELECT
+              s.`tenant_id`,
+              'AIDOPDEV_MYSQL',
+              'aidop_action_run_log',
+              CAST(s.`id` AS CHAR),
+              CAST(s.`id` AS CHAR),
+              JSON_OBJECT(
+                'id',          CAST(s.`id` AS CHAR),
+                'tenant_id',   s.`tenant_id`,
+                'action_code', s.`action_code`,
+                'biz_type',    s.`biz_type`,
+                'biz_id',      s.`biz_id`,
+                'biz_no',      s.`biz_no`,
+                'status',      s.`status`,
+                'message',     s.`message`,
+                'detail_json', s.`detail_json`,
+                'start_time',  DATE_FORMAT(s.`start_time`, '%Y-%m-%d %H:%i:%s'),
+                'end_time',    DATE_FORMAT(s.`end_time`,   '%Y-%m-%d %H:%i:%s'),
+                'create_time', DATE_FORMAT(s.`create_time`,'%Y-%m-%d %H:%i:%s')
+              ),
+              @BatchId, @Now, 'PENDING', @Now, @Now
+            {RepullRunningFrom}
+            ON DUPLICATE KEY UPDATE
+              `source_row_id`  = VALUES(`source_row_id`),
+              `raw_data`       = VALUES(`raw_data`),
+              `sync_batch_id`  = VALUES(`sync_batch_id`),
+              `sync_time`      = VALUES(`sync_time`),
+              `process_status` = 'PENDING',
+              `update_time`    = VALUES(`update_time`)
+            """;
+
+        // 返回值刻意丢弃:ODKU 的 affected rows 把「更新」计 2,不是逻辑记录数。
+        await _db.Ado.ExecuteCommandAsync(sql, ps);
+
+        if (logicalRows > 0)
+            _logger.LogInformation("RUNNING 回捞补偿命中 tenant={TenantId} rows={Rows}", tenantId, logicalRows);
+
+        return logicalRows;
+    }
+
+    /// <summary>
+    /// RUNNING 回捞的取数范围(FROM + WHERE)。被计数查询与 upsert 共用,保证两者永远同一集合。
+    /// </summary>
+    private const string RepullRunningFrom = """
+            FROM `aidop_action_run_log` s
+            WHERE s.`tenant_id` = @TenantId
+              AND s.`tenant_id` IS NOT NULL
+              AND s.`tenant_id` <> 0
+              AND EXISTS (
+                    SELECT 1 FROM `mdp_std_action_event` e
+                     WHERE e.`tenant_id` = @TenantId
+                       AND e.`action_status` = 'RUNNING'
+                       AND e.`action_run_id` = s.`id`
+                  )
+            """;
+
+    // ══════════════════════════════════════════════════════════════════════
+    // PHASE 3 · STG → STD(审计事实层,全状态保留)
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 贴源 → 标准事件层。Grain = <c>tenant_id + action_run_id</c>。
+    /// <para>RUNNING / SUCCESS / FAILED <b>全部保留</b> —— STD 是审计事实层,
+    /// 不因为 KPI 只需要 SUCCESS 就在此丢弃其它状态。</para>
+    /// <para>同一 <c>action_run_id</c> 重拉时,<c>ON DUPLICATE KEY UPDATE</c> 允许
+    /// RUNNING 被 SUCCESS/FAILED 更新(终态推进)。</para>
+    /// </summary>
+    public async Task<int> TransformStdFromStgAsync(
+        long tenantId, string batchId, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) throw new InvalidOperationException("TransformStdFromStgAsync 需要有效 tenantId。");
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var ps = new SugarParameter[]
+        {
+            new("@TenantId", tenantId),
+            new("@BatchId", batchId)
+        };
+
+        // 逻辑计数:本轮参与 STD transform 的 distinct action_run_id 数。
+        // 与下方 INSERT 共用同一份 FROM/WHERE(StdEligibleFrom),保证「计了什么」= 「写了什么」。
+        var logicalRows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.id'))) {StdEligibleFrom}", ps);
+
+        const string sql = $"""
+            INSERT INTO `mdp_std_action_event`
+              (`tenant_id`, `action_run_id`, `action_code`, `biz_type`, `biz_id`, `biz_no`,
+               `action_status`, `action_start_time`, `action_end_time`, `message`, `detail_json`,
+               `source_system`, `source_table`, `source_biz_key`, `sync_batch_id`)
+            SELECT
+              g.`tenant_id`,
+              CAST(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.id')) AS SIGNED),
+              JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.action_code')),
+              JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.biz_type')),
+              CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.biz_id')) REGEXP '^-?[0-9]+$'
+                   THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.biz_id')) AS SIGNED) END,
+              NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.biz_no')), 'null'),
+              JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.status')),
+              CAST(REPLACE(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.start_time')), 'T', ' ') AS DATETIME),
+              CASE WHEN NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.end_time')), 'null') IS NULL
+                   THEN NULL
+                   ELSE CAST(REPLACE(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.end_time')), 'T', ' ') AS DATETIME) END,
+              NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.message')), 'null'),
+              CASE
+                WHEN JSON_EXTRACT(g.`raw_data`, '$.detail_json') IS NULL THEN NULL
+                WHEN JSON_VALID(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.detail_json')))
+                     THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.detail_json')) AS JSON)
+                ELSE JSON_EXTRACT(g.`raw_data`, '$.detail_json')
+              END,
+              g.`source_system`,
+              g.`source_table`,
+              g.`source_biz_key`,
+              @BatchId
+            {StdEligibleFrom}
+            ON DUPLICATE KEY UPDATE
+              `action_code`       = VALUES(`action_code`),
+              `biz_type`          = VALUES(`biz_type`),
+              `biz_id`            = VALUES(`biz_id`),
+              `biz_no`            = VALUES(`biz_no`),
+              `action_status`     = VALUES(`action_status`),
+              `action_start_time` = VALUES(`action_start_time`),
+              `action_end_time`   = VALUES(`action_end_time`),
+              `message`           = VALUES(`message`),
+              `detail_json`       = VALUES(`detail_json`),
+              `sync_batch_id`     = VALUES(`sync_batch_id`)
+            """;
+
+        // 返回值刻意丢弃:ODKU 的 affected rows 把「更新」计 2,不是逻辑记录数。
+        await _db.Ado.ExecuteCommandAsync(sql, ps);
+        return logicalRows;
+    }
+
+    /// <summary>
+    /// STD transform 的取数范围(FROM + WHERE)。被计数查询与 upsert 共用,保证两者永远同一集合。
+    /// </summary>
+    private const string StdEligibleFrom = """
+            FROM `mdp_stg_action_run` g
+            WHERE g.`tenant_id` = @TenantId
+              AND g.`tenant_id` <> 0
+              AND g.`source_table` = 'aidop_action_run_log'
+              AND JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.id')) REGEXP '^[0-9]+$'
+              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.action_code')), 'null') IS NOT NULL
+              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.biz_type')),    'null') IS NOT NULL
+              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.status')),      'null') IS NOT NULL
+              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.start_time')),  'null') IS NOT NULL
+            """;
+
+    // ══════════════════════════════════════════════════════════════════════
+    // PHASE 4 · STD → DWD KPI(FIRST VALID PAIR)
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 标准事件层 → 订单评审 KPI。Grain = <c>tenant_id + sales_order_id</c>。
+    ///
+    /// <para><b>算法(定版,不得改用 LAST PAIR / MAX)</b>:
+    /// ① 只取 <c>status='SUCCESS'</c>,FAILED 保留在 STD 但不参与配对;
+    /// ② ReviewStart = 评审 SUCCESS 的 <b>FIRST</b>(<c>ORDER BY action_start_time, action_run_id</c>);
+    /// ③ ReviewEnd = <b>首个 <c>action_end_time &gt; ReviewStart</c></b> 的确认 SUCCESS 的
+    ///    <c>action_end_time</c>(= 确认动作<b>完成</b>时刻,是 <c>progress='3'</c> 提交后的最近上界);
+    /// ④ ActualHours = 自然 elapsed 小时,2 位小数,<b>不扣夜间/周末/节假日</b>(本项目无工作日历 Authority)。</para>
+    ///
+    /// <para><b>冻结语义</b>:算法只依赖「第一条评审」与「首个晚于它的确认」,两者一旦产生即恒定,
+    /// 后续任意次重复点击都不会改写已形成的 KPI;因此重复执行结果完全一致(deterministic / idempotent)。</para>
+    ///
+    /// <para>本批<b>不计算</b> TargetHours / kpi_status —— 那依赖 S0 PI 匹配,属另一批次。</para>
+    /// </summary>
+    public async Task<int> BuildOrderReviewKpiAsync(
+        long tenantId, string batchId, DateTime now, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) throw new InvalidOperationException("BuildOrderReviewKpiAsync 需要有效 tenantId。");
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var ps = new SugarParameter[]
+        {
+            new("@TenantId", tenantId),
+            new("@BatchId", batchId),
+            new("@Now", now)
+        };
+
+        // 逻辑计数:本轮参与 KPI transform 的 distinct (tenant_id, sales_order_id) 数,
+        // 即下方 CTE `agg` 的基数 —— 与写入集合一一对应(agg 是最终 SELECT 的驱动表)。
+        var logicalRows = await _db.Ado.GetIntAsync($"""
+            SELECT COUNT(*) FROM (
+              SELECT `tenant_id`, `biz_id` {KpiEventFrom} GROUP BY `tenant_id`, `biz_id`
+            ) agg
+            """, ps);
+
+        const string sql = $"""
+            INSERT INTO `dwd_order_review_kpi`
+              (`tenant_id`, `sales_order_id`, `sales_order_no`,
+               `review_action_run_id`, `confirm_action_run_id`,
+               `review_start_time`, `review_end_time`, `actual_hours`, `calculation_status`,
+               `review_success_count`, `confirm_success_count`,
+               `review_failed_count`, `confirm_failed_count`,
+               `calc_batch_id`, `calc_time`)
+            WITH ev AS (
+              SELECT `tenant_id`, `biz_id`, `biz_no`, `action_run_id`, `action_code`,
+                     `action_status`, `action_start_time`, `action_end_time`
+              {KpiEventFrom}
+            ),
+            agg AS (
+              SELECT `tenant_id`, `biz_id`,
+                     MAX(`biz_no`) AS biz_no,
+                     SUM(`action_code` = 'S1_ORDER_REVIEW'    AND `action_status` = 'SUCCESS') AS rs,
+                     SUM(`action_code` = 'S1_DELIVERY_CONFIRM' AND `action_status` = 'SUCCESS') AS cs,
+                     SUM(`action_code` = 'S1_ORDER_REVIEW'    AND `action_status` = 'FAILED')  AS rf,
+                     SUM(`action_code` = 'S1_DELIVERY_CONFIRM' AND `action_status` = 'FAILED')  AS cf
+                FROM ev GROUP BY `tenant_id`, `biz_id`
+            ),
+            rv AS (
+              SELECT `tenant_id`, `biz_id`, `action_run_id`, `action_start_time`,
+                     ROW_NUMBER() OVER (PARTITION BY `tenant_id`, `biz_id`
+                                        ORDER BY `action_start_time` ASC, `action_run_id` ASC) AS rn
+                FROM ev
+               WHERE `action_code` = 'S1_ORDER_REVIEW' AND `action_status` = 'SUCCESS'
+            ),
+            r1 AS (SELECT * FROM rv WHERE rn = 1),
+            cv AS (
+              SELECT c.`tenant_id`, c.`biz_id`, c.`action_run_id`, c.`action_end_time`,
+                     ROW_NUMBER() OVER (PARTITION BY c.`tenant_id`, c.`biz_id`
+                                        ORDER BY c.`action_end_time` ASC, c.`action_run_id` ASC) AS rn
+                FROM ev c
+                JOIN r1 ON r1.`tenant_id` = c.`tenant_id` AND r1.`biz_id` = c.`biz_id`
+               WHERE c.`action_code`   = 'S1_DELIVERY_CONFIRM'
+                 AND c.`action_status` = 'SUCCESS'
+                 AND c.`action_end_time` IS NOT NULL
+                 AND c.`action_end_time` > r1.`action_start_time`
+            ),
+            c1 AS (SELECT * FROM cv WHERE rn = 1)
+            SELECT
+              a.`tenant_id`,
+              a.`biz_id`,
+              a.biz_no,
+              r1.`action_run_id`,
+              c1.`action_run_id`,
+              r1.`action_start_time`,
+              c1.`action_end_time`,
+              CASE WHEN r1.`action_start_time` IS NOT NULL AND c1.`action_end_time` IS NOT NULL
+                   THEN ROUND(TIMESTAMPDIFF(SECOND, r1.`action_start_time`, c1.`action_end_time`) / 3600.0, 2)
+              END,
+              CASE
+                WHEN r1.`action_start_time` IS NULL AND a.cs = 0 THEN 'NO_START'
+                WHEN r1.`action_start_time` IS NULL                THEN 'INVALID'
+                WHEN c1.`action_end_time`   IS NOT NULL            THEN 'OK'
+                WHEN a.cs = 0                                      THEN 'IN_PROGRESS'
+                ELSE 'INVALID_TIME_SEQUENCE'
+              END,
+              a.rs, a.cs, a.rf, a.cf,
+              @BatchId, @Now
+            FROM agg a
+            LEFT JOIN r1 ON r1.`tenant_id` = a.`tenant_id` AND r1.`biz_id` = a.`biz_id`
+            LEFT JOIN c1 ON c1.`tenant_id` = a.`tenant_id` AND c1.`biz_id` = a.`biz_id`
+            ON DUPLICATE KEY UPDATE
+              `sales_order_no`        = VALUES(`sales_order_no`),
+              `review_action_run_id`  = VALUES(`review_action_run_id`),
+              `confirm_action_run_id` = VALUES(`confirm_action_run_id`),
+              `review_start_time`     = VALUES(`review_start_time`),
+              `review_end_time`       = VALUES(`review_end_time`),
+              `actual_hours`          = VALUES(`actual_hours`),
+              `calculation_status`    = VALUES(`calculation_status`),
+              `review_success_count`  = VALUES(`review_success_count`),
+              `confirm_success_count` = VALUES(`confirm_success_count`),
+              `review_failed_count`   = VALUES(`review_failed_count`),
+              `confirm_failed_count`  = VALUES(`confirm_failed_count`),
+              `calc_batch_id`         = VALUES(`calc_batch_id`),
+              `calc_time`             = VALUES(`calc_time`)
+            """;
+
+        // 返回值刻意丢弃:ODKU 的 affected rows 把「更新」计 2,不是逻辑记录数。
+        await _db.Ado.ExecuteCommandAsync(sql, ps);
+        return logicalRows;
+    }
+
+    /// <summary>
+    /// KPI transform 的事件取数范围(FROM + WHERE)。被计数查询与 CTE <c>ev</c> 共用,
+    /// 保证「计的订单数」与「写入的订单集合」同源。
+    /// </summary>
+    private const string KpiEventFrom = """
+                FROM `mdp_std_action_event`
+               WHERE `tenant_id` = @TenantId
+                 AND `biz_type`  = 'crm_seorder'
+                 AND `biz_id` IS NOT NULL
+                 AND `action_code` IN ('S1_ORDER_REVIEW', 'S1_DELIVERY_CONFIRM')
+            """;
+}
+
+/// <summary>
+/// 动作事件链路跑批结果。
+///
+/// <para><b>计数语义统一为「本轮参与处理的逻辑对象数」</b>,不是数据库 affected rows。
+/// 原因:<c>INSERT … ON DUPLICATE KEY UPDATE</c> 在 MySQL 下「插入计 1、更新计 2」,
+/// 直接返回会让「什么都没变」的第二次跑批显示成处理量翻倍,属于虚报。
+/// 因此每层都改为对该层的取数集合求 <c>COUNT(DISTINCT 业务键)</c>:
+/// 一个 <c>action_run_id</c> 最多计 1、一张销售订单最多计 1,重复 upsert 不翻倍。</para>
+/// </summary>
+public sealed class ActionEventSyncResult
+{
+    public string BatchId { get; set; } = "";
+    public long TenantId { get; set; }
+
+    /// <summary>本轮实际进入贴源处理链的 source event 数(增量拉取到的源行数)。</summary>
+    public int StageRows { get; set; }
+
+    /// <summary>本轮被 RUNNING 回捞补偿重拉的 distinct action_run_id 数。</summary>
+    public int StageRepulledRunning { get; set; }
+
+    /// <summary>本轮参与 STD transform 的 distinct action_run_id 数。</summary>
+    public int StandardRows { get; set; }
+
+    /// <summary>本轮参与 KPI transform 的 distinct 销售订单数。</summary>
+    public int KpiRows { get; set; }
+}

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1190,6 +1190,21 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             IsHide = true,
             Remark = "SO 订单链路全景(隐藏路由,由总览页跳转进入)"
         };
+        // S8-STAGE1-ORDER-REVIEW-ACTUALHOURS-1:Stage-1 评审耗时(真实数据,来自数据中台 dwd_order_review_kpi)。
+        yield return new SysMenu
+        {
+            Id = s8MonitoringDirId + 7,
+            Pid = s8MonitoringDirId,
+            Title = "订单评审耗时",
+            Path = "/aidop/s8/monitoring/order-review-hours",
+            Name = "aidopS8OrderReviewActualHours",
+            Component = "/aidop/s8/monitoring/OrderReviewActualHoursPage",
+            Icon = "ele-Timer",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 16,
+            Remark = "S8 Stage-1 订单评审实际耗时(数据中台 dwd_order_review_kpi,无 seed 回落)"
+        };
 
         // 可见业务页(「异常监控看板」复用自动生成的 1322000000027 菜单位)
         yield return new SysMenu

+ 133 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8OrderReviewKpiService.cs

@@ -0,0 +1,133 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Order;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
+
+/// <summary>
+/// S8 Stage-1「订单评审」实际耗时只读服务。
+///
+/// <para><b>数据来源边界(Principle 2)</b>:本服务<b>只读 <c>dwd_order_review_kpi</c></b>,
+/// 不触碰 <c>aidop_action_run_log</c> / <c>crm_seorder</c> / <c>crm_seorderentry</c> /
+/// <c>ApprovalFlow*</c>。配对与过滤已在 <see cref="ActionEventMdpSyncService"/> 的中台链路内完成。</para>
+///
+/// <para><b>租户边界(Principle 1)</b>:租户一律由 <see cref="AidopTenantScope.ResolveOrThrow"/>
+/// 从认证后的 JWT 解析,<b>不接受前端传入的 tenantId</b>,无默认回退。</para>
+/// </summary>
+public class S8OrderReviewKpiService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly ActionEventMdpSyncService _syncService;
+
+    public S8OrderReviewKpiService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        ActionEventMdpSyncService syncService)
+    {
+        _db = db;
+        _userManager = userManager;
+        _syncService = syncService;
+    }
+
+    /// <summary>分页查询本租户的订单评审耗时。</summary>
+    public async Task<AdoS8OrderReviewKpiListDto> GetListAsync(AdoS8OrderReviewKpiQueryDto query)
+    {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+        var page = query.Page <= 0 ? 1 : query.Page;
+        var pageSize = query.PageSize is <= 0 or > 200 ? 20 : query.PageSize;
+
+        var where = "`tenant_id` = @TenantId";
+        var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
+
+        if (!string.IsNullOrWhiteSpace(query.OrderNo))
+        {
+            where += " AND `sales_order_no` LIKE @OrderNo";
+            ps.Add(new SugarParameter("@OrderNo", $"%{query.OrderNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(query.CalculationStatus))
+        {
+            where += " AND `calculation_status` = @CalcStatus";
+            ps.Add(new SugarParameter("@CalcStatus", query.CalculationStatus.Trim().ToUpperInvariant()));
+        }
+
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(*) FROM `dwd_order_review_kpi` WHERE {where}", ps);
+
+        ps.Add(new SugarParameter("@Skip", (page - 1) * pageSize));
+        ps.Add(new SugarParameter("@Take", pageSize));
+
+        var rows = await _db.Ado.SqlQueryAsync<AdoS8OrderReviewKpiRowDto>($"""
+            SELECT `sales_order_id`        AS SalesOrderId,
+                   `sales_order_no`        AS SalesOrderNo,
+                   `review_start_time`     AS ReviewStartTime,
+                   `review_end_time`       AS ReviewEndTime,
+                   `actual_hours`          AS ActualHours,
+                   `calculation_status`    AS CalculationStatus,
+                   `review_success_count`  AS ReviewSuccessCount,
+                   `confirm_success_count` AS ConfirmSuccessCount,
+                   `review_failed_count`   AS ReviewFailedCount,
+                   `confirm_failed_count`  AS ConfirmFailedCount,
+                   `review_action_run_id`  AS ReviewActionRunId,
+                   `confirm_action_run_id` AS ConfirmActionRunId,
+                   `calc_time`             AS CalcTime
+              FROM `dwd_order_review_kpi`
+             WHERE {where}
+             ORDER BY `review_start_time` DESC, `sales_order_id` DESC
+             LIMIT @Skip, @Take
+            """, ps);
+
+        return new AdoS8OrderReviewKpiListDto
+        {
+            List = rows ?? [],
+            Total = total,
+            Page = page,
+            PageSize = pageSize
+        };
+    }
+
+    /// <summary>
+    /// 本租户 Stage-1 评审耗时汇总。
+    /// <para>均值只对 <c>calculation_status='OK'</c> 求 —— 未闭环订单不按 0 参与,避免把 KPI 拉低成假象。</para>
+    /// </summary>
+    public async Task<AdoS8OrderReviewKpiSummaryDto> GetSummaryAsync()
+    {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+
+        var list = await _db.Ado.SqlQueryAsync<AdoS8OrderReviewKpiSummaryDto>("""
+            SELECT COUNT(*)                                                     AS TotalOrders,
+                   SUM(`calculation_status` = 'OK')                             AS OkCount,
+                   SUM(`calculation_status` = 'IN_PROGRESS')                    AS InProgressCount,
+                   SUM(`calculation_status` = 'NO_START')                       AS NoStartCount,
+                   SUM(`calculation_status` = 'INVALID')                        AS InvalidCount,
+                   SUM(`calculation_status` = 'INVALID_TIME_SEQUENCE')          AS InvalidTimeSequenceCount,
+                   ROUND(AVG(CASE WHEN `calculation_status` = 'OK' THEN `actual_hours` END), 2) AS AvgActualHours,
+                   MIN(CASE WHEN `calculation_status` = 'OK' THEN `actual_hours` END)           AS MinActualHours,
+                   MAX(CASE WHEN `calculation_status` = 'OK' THEN `actual_hours` END)           AS MaxActualHours,
+                   MAX(`calc_time`)                                             AS LastCalcTime
+              FROM `dwd_order_review_kpi`
+             WHERE `tenant_id` = @TenantId
+            """, new List<SugarParameter> { new("@TenantId", tenantId) });
+
+        return list?.FirstOrDefault() ?? new AdoS8OrderReviewKpiSummaryDto();
+    }
+
+    /// <summary>
+    /// 手动触发链路重算:源 → STG(增量 + RUNNING 回捞)→ STD → DWD KPI。
+    /// 只对当前登录租户生效;幂等,可重复调用。
+    /// </summary>
+    public async Task<AdoS8OrderReviewKpiRefreshResultDto> RefreshAsync(CancellationToken cancellationToken = default)
+    {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+        var r = await _syncService.RunAsync(tenantId, cancellationToken);
+        return new AdoS8OrderReviewKpiRefreshResultDto
+        {
+            BatchId = r.BatchId,
+            StageRows = r.StageRows,
+            StageRepulledRunning = r.StageRepulledRunning,
+            StandardRows = r.StandardRows,
+            KpiRows = r.KpiRows
+        };
+    }
+}