Przeglądaj źródła

feat(s8): integrate real order archive read model

订单执行档案首次展示来自数据中台的真实销售订单,不再依赖 SEED 基线。

  mdp_std_so(GROUP BY tenant_id + order_id)
    → LEFT JOIN dwd_order_review_kpi(tenant_id + sales_order_id)
    → 固定五阶段骨架
    → 现有订单执行档案页

身份契约:tenant_id + order_id(= crm_seorder.Id)。order_no 只作展示与搜索,
不作主键;前端不再用 orderCode.replace() 拼造订单 id,详情改走
/orders/by-id/{salesOrderId},按订单号定位的旧 endpoint 仅留给 SEED 路径。

聚合口径:mdp_std_so 是订单行粒度,但 Header 列在同一 order_id 的各行取值实证
一致,故 MAX() 只是 SQL 投影手段、不含业务含义;progress / factory_id /
item_name / item_spec 是行级字段,不升格为 Header。

LEFT JOIN 而非 INNER JOIN:有订单但尚未评审的订单必须照常出现在档案里。
实测 UATA-A05-SO 在 mdp_std_so 有、在 KPI 表无,列表仍展示、评审字段全空。

五阶段是业务设计,不由数据有无决定,因此 Stage 2~5 恒定存在但字段为空,
绝不从 SEED 拼回来。无真实 Authority 的字段(产品线 / 区域 / 优先级 / 客户分层 /
目标周期 / 各类偏差)一律返回空并渲染 "--",不补 Seed、不按客户名推断 ——
真实订单配假 KPI 比没有更危险。同理前端去掉了 customerType→'KA'、
priority→'P2' 两处旧兜底。

ActualHours 新增为独立的 nullable 字段,不复用 ActualDays(后者单位不同且已被
KPI 反推逻辑占用)。阶段状态仍以 pending 下发(本批不做红黄绿评级),仅按
"有无结束时间/开始时间"这一纯事实细分出 已完成 / 进行中 / 未到达。

租户是唯一安全边界:租户只来自 JWT,DTO 不含 tenantId;实测伪造
?tenantId=<其它租户> 返回结果逐条相同,跨租户 order_id 查询返回 204、
跨租户单号搜索 0 条。

SEED 基线(ado_s8_order_flow_*)未删除、未改结构、未写入真实订单,
继续作为 fixture / 回归基线,与 REAL 运行时严格隔离(DataSource 区分)。

已知约束:mdp_std_so 的 latest-batch + 纯 upsert 会保留源侧已删除订单的陈旧行
(租户 797403760988229 现有 386/414 行陈旧,不可作业务正确性验收);在该
transform 根治前,禁止启用 S1_SEORDER_API / S1_SEORDER_ENTRY_API 等竞争 writer。

版本:server 1.0.531 / Web 2.4.395
YY968XX 1 miesiąc temu
rodzic
commit
919c0d9364

+ 1 - 1
Web/package.json

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

+ 12 - 1
Web/src/stores/orderExecution.ts

@@ -24,6 +24,7 @@ import {
 	getOrderFlowAggregate,
 	getOrderFlowChain,
 	getOrderFlowOrder,
+	getRealOrderFlowOrderById,
 	getOrderFlowOrders,
 	getOrderFlowProductDesignDrawings,
 	type OrderFlowProductDesignDrawings,
@@ -202,13 +203,23 @@ export const useOrderExecutionStore = defineStore('orderExecution', {
 			}
 		},
 		// t3h:展开订单时按需取详情;lifecycle 已有则跳过,失败不清空 orders。
+		/**
+		 * 展开订单时取五阶段详情。
+		 *
+		 * REAL runtime 一律按 **salesOrderId** 定位(后端再叠加当前租户),
+		 * 只有 SEED fixture(无 salesOrderId)才回退到按订单号定位的旧接口。
+		 * 两条路径显式隔离,REAL 不会悄悄回落 orderCode。
+		 */
 		async loadDetailFromDomain(orderCode: string) {
 			if (!orderCode) return;
 			const index = this.orders.findIndex((o) => o.soNo === orderCode);
 			if (index < 0) return;
 			if (this.orders[index].lifecycle.length > 0) return;
+			const realId = this.orders[index].salesOrderId;
 			try {
-				const detail = await getOrderFlowOrder(orderCode);
+				const detail = realId != null
+					? await getRealOrderFlowOrderById(realId)
+					: await getOrderFlowOrder(orderCode);
 				if (!detail) return;
 				const lifecycle = mapDomainOrderDetailLifecycle(detail);
 				const next = this.orders.slice();

+ 32 - 4
Web/src/views/aidop/s8/api/s8OrderFlowDomainApi.ts

@@ -23,6 +23,19 @@ export interface OrderFlowOrderQuery {
 
 export interface OrderFlowOrderListItem {
 	orderCode: string;
+	/**
+	 * 真实销售订单头 Id = mdp_std_so.order_id = crm_seorder.Id。REAL runtime 的唯一身份;SEED 路径为 null。
+	 * 注意:后端 long 经框架序列化为**字符串**(雪花 Id 超出 JS 安全整数范围),故类型含 string。
+	 * 只做等值传递与 URL 拼接,禁止做数值运算。
+	 */
+	salesOrderId: number | string | null;
+	/** 真实销售订单号。仅展示 / 搜索,不作主键。 */
+	orderNo: string | null;
+	customerId: number | null;
+	orderDate: string | null;
+	orderType: number | null;
+	orderStatus: string | null;
+	flowState: string | null;
 	productName: string;
 	productLine: string;
 	customerCode: string;
@@ -34,8 +47,9 @@ export interface OrderFlowOrderListItem {
 	currentOrderFlowCode: string;
 	currentOrderFlowName: string;
 	currentStatus: string;
-	releaseAt: string;
-	targetCycleDays: number;
+	releaseAt: string | null;
+	/** SEED 的目标周期;REAL runtime 无 Authority,恒为 null。 */
+	targetCycleDays: number | null;
 	actualCycleDays: number | null;
 	currentCycleDays: number | null;
 	nodeVarianceDays: number | null;
@@ -74,11 +88,15 @@ export interface OrderFlowStage {
 	orderFlowName: string;
 	ownerDept: string | null;
 	sortNo: number;
-	plannedDays: number;
+	plannedDays: number | null;
 	actualDays: number | null;
-	targetAt: string;
+	targetAt: string | null;
 	actualStartAt: string | null;
 	actualEndAt: string | null;
+	/** 阶段实际耗时(小时)。REAL runtime 的 ORDER_REVIEW 取 dwd_order_review_kpi.actual_hours;其余为 null。 */
+	actualHours: number | null;
+	/** Stage-1 KPI 计算状态:OK / NO_START / IN_PROGRESS / INVALID / INVALID_TIME_SEQUENCE。 */
+	calculationStatus: string | null;
 	status: string;
 	nodeVarianceDays: number | null;
 	cumulativeVarianceDays: number | null;
@@ -386,6 +404,16 @@ export function getOrderFlowOrders(query: OrderFlowOrderQuery = {}) {
 		.then(unwrap);
 }
 
+/**
+ * 真实订单详情 —— 定位 Authority = 当前租户(JWT) + salesOrderId。
+ * REAL runtime 必须走这个,禁止回退到按订单号定位的 getOrderFlowOrder。
+ */
+export function getRealOrderFlowOrderById(salesOrderId: number | string) {
+	return service
+		.get<OrderFlowOrderDetail | null>(`${BASE}/orders/by-id/${encodeURIComponent(String(salesOrderId))}`)
+		.then(unwrap);
+}
+
 export function getOrderFlowOrder(orderCode: string) {
 	return service
 		.get<OrderFlowOrderDetail | null>(`${BASE}/orders/${encodeURIComponent(orderCode)}`, { params: withAidopTenantFactoryParams({}) })

+ 5 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/ChainStageFilterBar.vue

@@ -52,8 +52,12 @@ function dedupe<T>(arr: T[]): T[] {
 const orderNoOptions = computed(() =>
 	dedupe(props.orders.map((o) => o.soNo)).map((v) => ({ value: v, label: v })),
 );
+// REAL runtime 下 mdp_std_so 没有客户分层 Authority,customerType 为 null。
+// 必须先滤掉 null,否则会给 el-option 传 null 值(Element Plus 会告警,且产生一个无意义的空选项)。
 const customerTypeOptions = computed(() =>
-	dedupe(props.orders.map((o) => o.customerType)).map((v) => ({ value: v, label: v })),
+	dedupe(props.orders.map((o) => o.customerType))
+		.filter((v): v is 'KA' | 'SMB' | 'MICRO' => !!v)
+		.map((v) => ({ value: v, label: v })),
 );
 const regionOptions = computed(() =>
 	dedupe(props.orders.map((o) => o.region).filter(Boolean)).map((v) => ({ value: v, label: v })),

+ 7 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderCard.vue

@@ -51,7 +51,13 @@ function onChain() {
 				<span class="oe-order-card__product">{{ view.productName }}</span>
 			</div>
 			<div class="oe-order-card__actions">
-				<span class="oe-order-card__priority" :class="`oe-order-card__priority--${view.priority.toLowerCase()}`">
+				<!-- REAL runtime 无优先级 Authority(mdp_std_so 不含该字段)→ 整个徽标不渲染,
+				     不显示任何占位值,更不回落默认 P2。 -->
+				<span
+					v-if="view.priority"
+					class="oe-order-card__priority"
+					:class="`oe-order-card__priority--${view.priority.toLowerCase()}`"
+				>
 					{{ view.priority }}
 				</span>
 				<span class="oe-order-card__workflow" :class="`oe-order-card__workflow--${view.workflowTone}`">

+ 16 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderList.vue

@@ -138,6 +138,8 @@ interface StageColumn {
 	kpiText: string;
 	targetDate: string;
 	actualReachedAt: string;
+	/** 阶段实际耗时文案。REAL runtime 仅 ORDER_REVIEW 有值,其余阶段与 SEED 均为 "--"。 */
+	actualHoursText: string;
 	status: OrderExecutionStatusTone;
 	statusLabel: string;
 	// S8-DEMO-CORE-FIELD-COMPLETE-1-FOLLOWUP-1:阶段级订单状态,基于该阶段自身 cumulativeVarianceDays。
@@ -162,6 +164,9 @@ function buildStageColumns(stages: StageSnapshot[]): StageColumn[] {
 			kpiText: kpi != null ? kpi.toFixed(1) : '--',
 			targetDate: stage.targetDate || '--',
 			actualReachedAt: stage.actualReachedAt ?? '尚未达成',
+			// 只有真实闭环(actualHours 非空)才显示数字。
+			// 未闭环 / 无 KPI / Stage 2~5 一律 "--" —— 不补 0,0h 会被误读成"秒批"。
+			actualHoursText: stage.actualHours != null ? `${Number(stage.actualHours).toFixed(2)}h` : '--',
 			status: node.tone,
 			statusLabel: node.label,
 			orderStatus: order,
@@ -230,14 +235,18 @@ const rows = computed(() =>
 					<span class="oe-order-row__product">{{ row.productName }}</span>
 					<span class="oe-order-row__customer">
 						<span class="oe-order-row__customer-name">{{ row.customerName }}</span>
+						<!-- REAL runtime 无客户分层 Authority → 不渲染标签(不占位、不兜底 KA)。 -->
 						<span
+							v-if="row.customerType"
 							class="oe-order-row__type"
 							:class="`oe-order-row__type--${row.customerType.toLowerCase()}`"
 						>{{ row.customerType }}</span>
 					</span>
 					<span class="oe-order-row__chip oe-order-row__chip--line">{{ row.productLine }}</span>
 					<span class="oe-order-row__chip oe-order-row__chip--region">{{ row.region }}</span>
+					<!-- 同上:REAL runtime 无优先级 Authority → 不渲染。 -->
 					<span
+						v-if="row.priority"
 						class="oe-order-row__chip oe-order-row__chip--priority"
 						:class="`oe-order-row__chip--priority-${row.priority.toLowerCase()}`"
 					>{{ row.priority }}</span>
@@ -429,7 +438,7 @@ const rows = computed(() =>
 										<td v-for="col in buildStageColumns(row.raw.lifecycle)" :key="col.key">
 											{{ col.kpiText }}
 										</td>
-										<td class="oe-detail-matrix-table__workbench-cell" rowspan="6">
+										<td class="oe-detail-matrix-table__workbench-cell" rowspan="7">
 											<div class="oe-workbench">
 												<div class="oe-workbench__title">项目经理工作台</div>
 												<template v-if="workbenchStage(row.so, row.raw)">
@@ -489,6 +498,12 @@ const rows = computed(() =>
 											{{ isStageRevealed(row.so, row.raw.lifecycle, col.key) ? col.actualReachedAt : '--' }}
 										</td>
 									</tr>
+									<tr>
+										<td class="oe-detail-matrix-table__metric">实际耗时</td>
+										<td v-for="col in buildStageColumns(row.raw.lifecycle)" :key="col.key">
+											{{ isStageRevealed(row.so, row.raw.lifecycle, col.key) ? col.actualHoursText : '--' }}
+										</td>
+									</tr>
 									<tr>
 										<td class="oe-detail-matrix-table__metric">节点状态</td>
 										<td v-for="col in buildStageColumns(row.raw.lifecycle)" :key="col.key">

+ 11 - 1
Web/src/views/aidop/s8/monitoring/components/order-execution/statusMapping.ts

@@ -42,11 +42,21 @@ function toneByVariance(days: number | null | undefined): OrderExecutionStatusTo
 // 仅按 actualStartedAt 细分**标签**,tone 不变(不引入新配色、不改任何 CSS / 汇总口径)。
 const NODE_LABEL_IN_PROGRESS = '进行中';
 
+// REAL runtime(mdp_std_so + dwd_order_review_kpi)下阶段一律以 status='pending' 下发 ——
+// 本批不做红黄绿达标评级,没有任何评级 Authority,green/yellow/red 都会是凭空结论。
+// 但「已经有结束时间」是纯事实,不带评级含义,因此在同一条 pending 细分链上再分出「已完成」。
+// 否则已闭环的真实订单会被标成「进行中」,与同页「完成时间」「实际耗时」自相矛盾。
+const NODE_LABEL_COMPLETED = '已完成';
+
 export function computeNodeStatus(stage: StageSnapshot | null | undefined): StatusVisual {
 	if (!stage) return { tone: 'pending', label: NODE_LABEL.pending };
 	const s = stage.status;
 	if (s === 'green' || s === 'yellow' || s === 'red' || s === 'pending') {
-		const label = s === 'pending' && stage.actualStartedAt ? NODE_LABEL_IN_PROGRESS : NODE_LABEL[s];
+		let label = NODE_LABEL[s];
+		if (s === 'pending') {
+			if (stage.actualReachedAt) label = NODE_LABEL_COMPLETED;
+			else if (stage.actualStartedAt) label = NODE_LABEL_IN_PROGRESS;
+		}
 		return { tone: s, label };
 	}
 	const tone = toneByVariance(stage.nodeVarianceDays);

+ 2 - 0
Web/src/views/aidop/s8/monitoring/data/order-execution/builder.ts

@@ -251,6 +251,8 @@ export const buildSalesOrderExecution = (
   return {
     soNo: order.id.replace('ORD', 'SO'),
     orderId: order.id,
+    // 本 builder 只服务 SEED / fixture 场景,没有真实销售订单头 Id。
+    salesOrderId: null,
     productName: order.productName,
     productLine: order.productLine,
     customerName: order.customer,

+ 19 - 6
Web/src/views/aidop/s8/monitoring/data/order-execution/domainMapper.ts

@@ -60,12 +60,17 @@ function toMonthDayTime(s: string | null | undefined): string {
 	return m ? m[1] : s;
 }
 
-function asCustomerType(v: string): SalesOrderExecution['customerType'] {
-	return v === 'KA' || v === 'SMB' || v === 'MICRO' ? v : 'KA';
+// REAL runtime 下 mdp_std_so 没有客户分层 Authority,后端返回空串。
+// 此时必须给 null(渲染 "--"),**不得**沿用旧的 'KA' 兜底 —— 那是凭空造值。
+function asCustomerType(v: string | null | undefined): SalesOrderExecution['customerType'] {
+	if (!v) return null;
+	return v === 'KA' || v === 'SMB' || v === 'MICRO' ? v : null;
 }
 
-function asPriority(v: string): SalesOrderExecution['priority'] {
-	return v === 'P1' || v === 'P2' || v === 'P3' ? v : 'P2';
+// 同上:REAL runtime 无优先级 Authority → null,不得兜底 'P2'。
+function asPriority(v: string | null | undefined): SalesOrderExecution['priority'] {
+	if (!v) return null;
+	return v === 'P1' || v === 'P2' || v === 'P3' ? v : null;
 }
 
 function asWorkflowStatus(v: string): SalesOrderExecution['workflowStatus'] {
@@ -107,6 +112,10 @@ export function mapDomainStage(stage: OrderFlowStage): StageSnapshot {
 		// S8-STEP5A-ORDER-EXEC-CLOSURE-1(F-2):透传阶段实际开始时点,供状态标签区分「进行中」/「未到达」。
 		actualStartedAt: stage.actualStartAt ? toMonthDayTime(stage.actualStartAt) : null,
 		actualDays: stage.actualDays,
+		// REAL runtime 的 ORDER_REVIEW 实际耗时(小时);SEED 与 Stage 2~5 为 null。
+		// 刻意与 actualDays 分开传递 —— 单位与语义不同,混用会污染 KPI 反推逻辑。
+		actualHours: stage.actualHours ?? null,
+		calculationStatus: stage.calculationStatus ?? null,
 		nodeVarianceDays: stage.nodeVarianceDays,
 		cumulativeVarianceDays: stage.cumulativeVarianceDays,
 		status: asStageStatus(stage.status),
@@ -222,9 +231,13 @@ function toStatMap(
 
 export function mapDomainOrderListItem(dto: OrderFlowOrderListItem): SalesOrderExecution {
 	const currentKey = mapOrderFlowCodeToNodeKey(dto.currentOrderFlowCode);
+	// 身份优先取真实 salesOrderId;只有 SEED 路径(salesOrderId 为空)才回退旧的展示派生值。
+	// REAL runtime 绝不再用订单号字符串拼 id。
+	const realId = dto.salesOrderId ?? null;
 	return {
-		soNo: dto.orderCode,
-		orderId: dto.orderCode.replace(/^SO/, 'ORD'),
+		soNo: dto.orderNo ?? dto.orderCode,
+		orderId: realId != null ? String(realId) : dto.orderCode.replace(/^SO/, 'ORD'),
+		salesOrderId: realId,
 		productName: dto.productName,
 		productLine: dto.productLine,
 		customerName: dto.customerName,

+ 19 - 3
Web/src/views/aidop/s8/monitoring/data/order-execution/types.ts

@@ -59,6 +59,10 @@ export interface StageSnapshot {
 	// actualReachedAt 取的是 actualEndAt(完成时点),无法表达在制中的阶段。
 	actualStartedAt?: string | null;
 	actualDays?: number | null;
+	/** 阶段实际耗时(小时)。REAL runtime 仅 ORDER_REVIEW 有值;SEED 与 Stage 2~5 为 null。 */
+	actualHours?: number | null;
+	/** Stage-1 KPI 计算状态:OK / NO_START / IN_PROGRESS / INVALID / INVALID_TIME_SEQUENCE。 */
+	calculationStatus?: string | null;
 	nodeVarianceDays: number | null;
 	cumulativeVarianceDays: number | null;
 	status: 'green' | 'yellow' | 'red' | 'pending';
@@ -67,14 +71,25 @@ export interface StageSnapshot {
 
 export interface SalesOrderExecution {
 	soNo: string;
+	/**
+	 * 订单身份。REAL runtime = mdp_std_so.order_id(= crm_seorder.Id)的字符串形式;
+	 * SEED 路径沿用旧的展示用派生值。**禁止再用订单号字符串拼接构造。**
+	 */
 	orderId: string;
+	/**
+	 * 真实销售订单头 Id。REAL runtime 必填,SEED 路径为 null。详情/下钻一律以它定位。
+	 * 后端 long 序列化为字符串(雪花 Id),只作等值传递,不参与数值运算。
+	 */
+	salesOrderId: number | string | null;
 	productName: string;
 	productLine: string;
 	customerName: string;
 	customerCode: string;
-	customerType: 'KA' | 'SMB' | 'MICRO';
+	/** 无真实 Authority 时为 null(REAL runtime);不得填默认值。 */
+	customerType: 'KA' | 'SMB' | 'MICRO' | null;
 	region: string;
-	priority: 'P1' | 'P2' | 'P3';
+	/** 无真实 Authority 时为 null(REAL runtime);不得填默认值。 */
+	priority: 'P1' | 'P2' | 'P3' | null;
 	workflowStatus: 'completed' | 'in_progress';
 	currentNode: string;
 	currentNodeKey: OrderNodeKey;
@@ -82,7 +97,8 @@ export interface SalesOrderExecution {
 	nodeStatus: 'green' | 'yellow' | 'red';
 	observedAt: string;
 	orderStartDate: string;
-	targetCycleDays: number;
+	/** SEED 的目标周期;REAL runtime 无 Authority,恒为 null。 */
+	targetCycleDays: number | null;
 	actualCycleDays: number | null;
 	currentCycleDays: number | null;
 	nodeVarianceDays: number | null;

+ 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.530</AssemblyVersion>
-    <FileVersion>1.0.530</FileVersion>
-    <Version>1.0.530</Version>
+    <AssemblyVersion>1.0.531</AssemblyVersion>
+    <FileVersion>1.0.531</FileVersion>
+    <Version>1.0.531</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 25 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderFlowController.cs

@@ -16,19 +16,40 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
 public class AdoS8OrderFlowController : ControllerBase
 {
     private readonly S8OrderFlowService _svc;
+    private readonly S8RealOrderArchiveService _realSvc;
 
-    public AdoS8OrderFlowController(S8OrderFlowService svc)
+    public AdoS8OrderFlowController(S8OrderFlowService svc, S8RealOrderArchiveService realSvc)
     {
         _svc = svc;
+        _realSvc = realSvc;
     }
 
-    /// <summary>订单档案列表(不分页,当前 baseline 20 单)。</summary>
+    /// <summary>
+    /// 订单档案列表 —— <b>REAL runtime</b>:真实销售订单,来源 <c>mdp_std_so</c>
+    /// (按 <c>tenant_id + order_id</c> 聚合为一订单一行),ORDER_REVIEW 事实 LEFT JOIN
+    /// <c>dwd_order_review_kpi</c>。
+    /// <para>已由 SEED(<c>ado_s8_order_flow_order</c>)换源;SEED 基线保留在
+    /// <see cref="S8OrderFlowService"/> 供 fixture / 回归,两路不混用。</para>
+    /// </summary>
     [HttpGet("orders")]
     [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> OrdersAsync([FromQuery] AdoS8OrderFlowOrderQueryDto query)
-        => Ok(await _svc.GetOrdersAsync(query));
+        => Ok(await _realSvc.GetRealOrdersAsync(query));
+
+    /// <summary>
+    /// 真实订单详情(含固定五阶段骨架)。
+    /// <para><b>定位 Authority = 当前租户 + salesOrderId</b>(= <c>crm_seorder.Id</c>),
+    /// 不再用订单号文本定位。</para>
+    /// </summary>
+    [HttpGet("orders/by-id/{salesOrderId}")]
+    [S8Permission(S8PermissionCatalog.DashboardRead)]
+    public async Task<IActionResult> RealOrderDetailAsync(long salesOrderId)
+        => Ok(await _realSvc.GetRealOrderAsync(salesOrderId));
 
-    /// <summary>单订单详情(含 lifecycle 5 阶段,不含 substep/unit)。</summary>
+    /// <summary>
+    /// 单订单详情(SEED 路径,按 orderCode 定位)。
+    /// <para>⚠️ 仅供 SEED fixture / 链路全景等既有消费方;<b>REAL runtime 不得回退到这里</b>。</para>
+    /// </summary>
     [HttpGet("orders/{orderCode}")]
     [S8Permission(S8PermissionCatalog.DashboardRead)]
     public async Task<IActionResult> OrderDetailAsync(string orderCode)

+ 59 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8OrderFlowDtos.cs

@@ -21,6 +21,37 @@ public class AdoS8OrderFlowOrderQueryDto
 
 public class AdoS8OrderFlowOrderListItemDto
 {
+    // ── REAL runtime(mdp_std_so)新增字段 ─────────────────────────────────────────────
+    // SEED 路径(ado_s8_order_flow_*)不填这些,保持 null;REAL 路径不填 SEED 的周期/偏差字段。
+    // 两条路径互不混用:靠 DataSource 区分(SEED / MDP_STD_SO)。
+
+    /// <summary>
+    /// 真实销售订单头 Id = <c>mdp_std_so.order_id</c> = <c>crm_seorder.Id</c>。
+    /// <b>REAL runtime 的唯一订单身份</b>;SEED 路径为 null。
+    /// 前端必须用它定位订单,禁止再用订单号文本或字符串拼接构造 id。
+    /// </summary>
+    public long? SalesOrderId { get; set; }
+
+    /// <summary>真实销售订单号 = <c>mdp_std_so.order_no</c>。**仅展示 / 搜索**,不作主键。</summary>
+    public string? OrderNo { get; set; }
+
+    /// <summary>客户 Id(Header Authority)。</summary>
+    public long? CustomerId { get; set; }
+
+    /// <summary>下单日期(Header Authority)。</summary>
+    public DateTime? OrderDate { get; set; }
+
+    /// <summary>订单类型(Header Authority,源端为 int 域)。</summary>
+    public int? OrderType { get; set; }
+
+    /// <summary>订单状态 OPEN/CLOSED(Header Authority)。</summary>
+    public string? OrderStatus { get; set; }
+
+    /// <summary>流程状态(Header Authority)。</summary>
+    public string? FlowState { get; set; }
+
+    // ── 既有字段 ─────────────────────────────────────────────────────────────────────
+
     public string OrderCode { get; set; } = string.Empty;
     public string ProductName { get; set; } = string.Empty;
     public string ProductLine { get; set; } = string.Empty;
@@ -33,8 +64,12 @@ public class AdoS8OrderFlowOrderListItemDto
     public string CurrentOrderFlowCode { get; set; } = string.Empty;
     public string CurrentOrderFlowName { get; set; } = string.Empty;
     public string CurrentStatus { get; set; } = string.Empty;
-    public DateTime ReleaseAt { get; set; }
-    public decimal TargetCycleDays { get; set; }
+    /// <summary>SEED 的下达时间。REAL runtime 无对应 Authority → null(改用 <see cref="OrderDate"/>)。</summary>
+    public DateTime? ReleaseAt { get; set; }
+
+    /// <summary>SEED 的目标周期。**REAL runtime 必须为 null** —— 真实订单没有目标周期 Authority,不得回落 SEED。</summary>
+    public decimal? TargetCycleDays { get; set; }
+
     public decimal? ActualCycleDays { get; set; }
     public decimal? CurrentCycleDays { get; set; }
     public decimal? NodeVarianceDays { get; set; }
@@ -66,11 +101,31 @@ public class AdoS8OrderFlowStageDto
     public string OrderFlowName { get; set; } = string.Empty;
     public string? OwnerDept { get; set; }
     public int SortNo { get; set; }
-    public decimal PlannedDays { get; set; }
+    /// <summary>SEED 的计划天数。REAL runtime 无 Authority → null。</summary>
+    public decimal? PlannedDays { get; set; }
+
+    /// <summary>SEED 的实际天数。REAL runtime 无 Authority → null。**不得把小时塞进这里**(它已被 KPI 反推逻辑占用)。</summary>
     public decimal? ActualDays { get; set; }
-    public DateTime TargetAt { get; set; }
+
+    /// <summary>SEED 的目标时间。REAL runtime 无 Authority → null。</summary>
+    public DateTime? TargetAt { get; set; }
+
     public DateTime? ActualStartAt { get; set; }
     public DateTime? ActualEndAt { get; set; }
+
+    /// <summary>
+    /// 阶段实际耗时(小时)。REAL runtime 下 ORDER_REVIEW 取
+    /// <c>dwd_order_review_kpi.actual_hours</c>;其余阶段与 SEED 路径为 null。
+    /// <b>独立字段,绝不复用 <see cref="ActualDays"/></b>(单位与语义都不同)。
+    /// </summary>
+    public decimal? ActualHours { get; set; }
+
+    /// <summary>
+    /// Stage-1 KPI 计算状态:OK / NO_START / IN_PROGRESS / INVALID / INVALID_TIME_SEQUENCE。
+    /// 仅 REAL runtime 的 ORDER_REVIEW 有值。
+    /// </summary>
+    public string? CalculationStatus { get; set; }
+
     public string Status { get; set; } = string.Empty;
     public decimal? NodeVarianceDays { get; set; }
     public decimal? CumulativeVarianceDays { get; set; }

+ 294 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8RealOrderArchiveService.cs

@@ -0,0 +1,294 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
+
+/// <summary>
+/// S8「订单执行档案」<b>真实订单</b> Read Model。
+///
+/// <para><b>数据链</b>:
+/// <c>mdp_std_so</c>(GROUP BY tenant_id + order_id)→ 订单 Header
+/// → LEFT JOIN <c>dwd_order_review_kpi</c> → ORDER_REVIEW 阶段事实
+/// → 固定五阶段骨架 → 前端档案页。</para>
+///
+/// <para><b>PRINCIPLE 1 · 租户隔离</b>:租户只来自 <see cref="AidopTenantScope.ResolveOrThrow"/>
+/// 解析的登录上下文;所有查询与 JOIN 均带 <c>tenant_id</c>,不接受前端传入 tenantId,
+/// 无默认租户、无 Factory 代偿。</para>
+///
+/// <para><b>PRINCIPLE 2 · 只走数据中台</b>:本服务只读 <c>mdp_std_so</c> 与
+/// <c>dwd_order_review_kpi</c>,不触碰 <c>crm_seorder</c> / <c>crm_seorderentry</c> /
+/// <c>CustMaster</c> / <c>aidop_action_run_log</c>。</para>
+///
+/// <para><b>与 SEED 严格隔离</b>:本服务<b>完全不读</b> <c>ado_s8_order_flow_*</c>。
+/// 真实订单的 Stage 2~5 一律返回空字段,绝不从 SEED 拼回来;SEED 基线由
+/// <see cref="S8OrderFlowService"/> 原样保留,供 fixture / 回归使用。</para>
+/// </summary>
+public class S8RealOrderArchiveService : ITransient
+{
+    /// <summary>本 Read Model 的数据来源标识,用于前端区分 REAL / SEED,禁止两路混用。</summary>
+    public const string RealDataSource = "MDP_STD_SO";
+
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public S8RealOrderArchiveService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// 订单 Header 投影。
+    ///
+    /// <para><b>聚合函数只是 SQL 投影手段,不是业务规则</b> —— <c>mdp_std_so</c> 是订单行粒度,
+    /// 而这几列已实证在同一 <c>order_id</c> 的所有行内取值完全一致(实测差异计数为 0),
+    /// 因此 <c>MAX()</c> 只是「从若干相同值里取一个」,不隐含「取最大」的业务含义。</para>
+    ///
+    /// <para><b>行级字段禁止升格为 Header</b>:<c>progress</c> / <c>factory_id</c> /
+    /// <c>item_name</c> / <c>item_spec</c> 逐行不同,故不在本投影内。</para>
+    /// </summary>
+    private const string HeaderProjection = """
+        SELECT `tenant_id`,
+               `order_id`,
+               MAX(`order_no`)       AS `order_no`,
+               MAX(`customer_id`)    AS `customer_id`,
+               MAX(`customer_no`)    AS `customer_no`,
+               MAX(`customer_name`)  AS `customer_name`,
+               MAX(`order_date`)     AS `order_date`,
+               MAX(`order_type`)     AS `order_type`,
+               MAX(`order_status`)   AS `order_status`,
+               MAX(`flow_state`)     AS `flow_state`
+          FROM `mdp_std_so`
+         WHERE `tenant_id` = @TenantId
+           AND `order_id` IS NOT NULL
+           AND `order_id` <> 0
+           AND `deleted_flag` = 0
+         GROUP BY `tenant_id`, `order_id`
+        """;
+
+    /// <summary>
+    /// 真实订单列表。一张销售订单一行。
+    /// <para>ORDER_REVIEW 事实用 <b>LEFT JOIN</b> 挂上 —— 有订单但尚未评审的订单
+    /// 必须照常出现在档案里,不能被 INNER JOIN 过滤掉。</para>
+    /// </summary>
+    public async Task<List<AdoS8OrderFlowOrderListItemDto>> GetRealOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
+    {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+
+        var where = "1=1";
+        var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
+
+        // 订单号 / 客户 模糊检索(order_no 仅作展示与搜索,不参与身份判定)
+        if (!string.IsNullOrWhiteSpace(query.Keyword))
+        {
+            where += " AND (h.`order_no` LIKE @Kw OR h.`customer_name` LIKE @Kw OR h.`customer_no` LIKE @Kw)";
+            ps.Add(new SugarParameter("@Kw", $"%{query.Keyword.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(query.CustomerCode))
+        {
+            where += " AND h.`customer_no` = @CustNo";
+            ps.Add(new SugarParameter("@CustNo", query.CustomerCode.Trim()));
+        }
+
+        var rows = await _db.Ado.SqlQueryAsync<RealOrderRow>($"""
+            SELECT h.`order_id`        AS SalesOrderId,
+                   h.`order_no`        AS OrderNo,
+                   h.`customer_id`     AS CustomerId,
+                   h.`customer_no`     AS CustomerNo,
+                   h.`customer_name`   AS CustomerName,
+                   h.`order_date`      AS OrderDate,
+                   h.`order_type`      AS OrderType,
+                   h.`order_status`    AS OrderStatus,
+                   h.`flow_state`      AS FlowState,
+                   k.`review_start_time`  AS ReviewStartTime,
+                   k.`review_end_time`    AS ReviewEndTime,
+                   k.`actual_hours`       AS ActualHours,
+                   k.`calculation_status` AS CalculationStatus
+              FROM ({HeaderProjection}) h
+              LEFT JOIN `dwd_order_review_kpi` k
+                     ON k.`tenant_id` = h.`tenant_id`
+                    AND k.`sales_order_id` = h.`order_id`
+             WHERE {where}
+             ORDER BY h.`order_date` DESC, h.`order_id` DESC
+            """, ps);
+
+        return (rows ?? []).Select(MapRealOrder).ToList();
+    }
+
+    /// <summary>
+    /// 单张真实订单详情(含固定五阶段骨架)。
+    /// <para><b>定位 Authority = <c>tenant_id + salesOrderId</c></b>,不接受订单号定位。</para>
+    /// </summary>
+    public async Task<AdoS8OrderFlowOrderDetailDto?> GetRealOrderAsync(long salesOrderId)
+    {
+        if (salesOrderId <= 0) return null;
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+
+        var rows = await _db.Ado.SqlQueryAsync<RealOrderRow>($"""
+            SELECT h.`order_id`        AS SalesOrderId,
+                   h.`order_no`        AS OrderNo,
+                   h.`customer_id`     AS CustomerId,
+                   h.`customer_no`     AS CustomerNo,
+                   h.`customer_name`   AS CustomerName,
+                   h.`order_date`      AS OrderDate,
+                   h.`order_type`      AS OrderType,
+                   h.`order_status`    AS OrderStatus,
+                   h.`flow_state`      AS FlowState,
+                   k.`review_start_time`  AS ReviewStartTime,
+                   k.`review_end_time`    AS ReviewEndTime,
+                   k.`actual_hours`       AS ActualHours,
+                   k.`calculation_status` AS CalculationStatus
+              FROM ({HeaderProjection}) h
+              LEFT JOIN `dwd_order_review_kpi` k
+                     ON k.`tenant_id` = h.`tenant_id`
+                    AND k.`sales_order_id` = h.`order_id`
+             WHERE h.`order_id` = @SalesOrderId
+             LIMIT 1
+            """, new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@SalesOrderId", salesOrderId)
+            });
+
+        var row = rows?.FirstOrDefault();
+        if (row == null) return null;
+
+        var item = MapRealOrder(row);
+        var detail = new AdoS8OrderFlowOrderDetailDto
+        {
+            SalesOrderId = item.SalesOrderId,
+            OrderNo = item.OrderNo,
+            CustomerId = item.CustomerId,
+            OrderDate = item.OrderDate,
+            OrderType = item.OrderType,
+            OrderStatus = item.OrderStatus,
+            FlowState = item.FlowState,
+            OrderCode = item.OrderCode,
+            CustomerCode = item.CustomerCode,
+            CustomerName = item.CustomerName,
+            WorkflowStatus = item.WorkflowStatus,
+            CurrentOrderFlowCode = item.CurrentOrderFlowCode,
+            CurrentOrderFlowName = item.CurrentOrderFlowName,
+            CurrentStatus = item.CurrentStatus,
+            DataSource = item.DataSource,
+            Lifecycle = BuildFiveStageSkeleton(row),
+        };
+        return detail;
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    // 映射
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 行 → 列表项。
+    /// <para><b>没有真实 Authority 的字段一律留空/留 null</b>:ProductName / ProductLine /
+    /// Region / Priority / CustomerType / TargetCycleDays / ActualCycleDays / 各类偏差。
+    /// 绝不用 SEED、PPT 常量、随机默认值或按客户名推断来填 —— 真实订单配假 KPI 比没有更危险。</para>
+    /// </summary>
+    private static AdoS8OrderFlowOrderListItemDto MapRealOrder(RealOrderRow r) => new()
+    {
+        SalesOrderId = r.SalesOrderId,
+        OrderNo = r.OrderNo,
+        CustomerId = r.CustomerId,
+        OrderDate = r.OrderDate,
+        OrderType = r.OrderType,
+        OrderStatus = r.OrderStatus,
+        FlowState = r.FlowState,
+
+        // OrderCode 在既有契约里承担「展示码」角色,REAL 下等于真实订单号;身份看 SalesOrderId。
+        OrderCode = r.OrderNo ?? string.Empty,
+        CustomerCode = r.CustomerNo ?? string.Empty,
+        CustomerName = r.CustomerName ?? string.Empty,
+
+        // 本批只有 ORDER_REVIEW 有事实,当前阶段恒定指向它;不推断"订单走到哪一步"。
+        CurrentOrderFlowCode = OrderFlowConstants.ORDER_REVIEW_PLAN_CALC,
+        CurrentOrderFlowName = OrderFlowConstants.ResolveName(OrderFlowConstants.ORDER_REVIEW_PLAN_CALC),
+        CurrentStatus = string.Empty,
+
+        // 订单状态用真实 Header Authority 映射,不猜。
+        WorkflowStatus = string.Equals(r.OrderStatus, "CLOSED", StringComparison.OrdinalIgnoreCase)
+            ? "completed"
+            : "in_progress",
+
+        DataSource = RealDataSource,
+
+        // ↓ 以下无真实 Authority,显式留空 —— 前端渲染 "--"
+        ProductName = string.Empty,
+        ProductLine = string.Empty,
+        CustomerType = string.Empty,
+        Region = string.Empty,
+        Priority = string.Empty,
+        ScenarioCode = string.Empty,
+        ExceptionStatus = string.Empty,
+        ReleaseAt = null,
+        TargetCycleDays = null,
+        ActualCycleDays = null,
+        CurrentCycleDays = null,
+        NodeVarianceDays = null,
+        CumulativeVarianceDays = null,
+        ResponseMinutes = null,
+        ProcessingMinutes = null,
+        TotalLossMinutes = null,
+    };
+
+    /// <summary>
+    /// 固定五阶段骨架。
+    /// <para><b>五阶段是 S8 的业务设计,不由「有没有数据」决定</b> —— 因此 Stage 2~5
+    /// 必须存在但字段为空;本批只有 ORDER_REVIEW 有真实事实。</para>
+    /// </summary>
+    private static List<AdoS8OrderFlowStageDto> BuildFiveStageSkeleton(RealOrderRow r)
+    {
+        var list = new List<AdoS8OrderFlowStageDto>();
+        for (var i = 0; i < OrderFlowConstants.All.Count; i++)
+        {
+            var code = OrderFlowConstants.All[i];
+            var isReview = code == OrderFlowConstants.ORDER_REVIEW_PLAN_CALC;
+
+            list.Add(new AdoS8OrderFlowStageDto
+            {
+                OrderFlowCode = code,
+                OrderFlowName = OrderFlowConstants.ResolveName(code),
+                SortNo = i + 1,
+                OwnerDept = null,
+
+                // 仅 ORDER_REVIEW 有真实事实;其余阶段一律 null,绝不从 SEED 拼。
+                ActualStartAt = isReview ? r.ReviewStartTime : null,
+                ActualEndAt = isReview ? r.ReviewEndTime : null,
+                ActualHours = isReview ? r.ActualHours : null,
+                CalculationStatus = isReview ? r.CalculationStatus : null,
+
+                // 目标/计划/偏差全部属 TargetHours 域,本批无 Authority → null
+                PlannedDays = null,
+                ActualDays = null,
+                TargetAt = null,
+                NodeVarianceDays = null,
+                CumulativeVarianceDays = null,
+
+                // 状态色本批不评级(红黄绿属另一批次);统一 pending,由前端按时间细分「进行中 / 未到达」
+                Status = "pending",
+                Substeps = [],
+            });
+        }
+        return list;
+    }
+
+    /// <summary>SQL 投影行,字段名与 SELECT 别名一一对应。</summary>
+    private sealed class RealOrderRow
+    {
+        public long SalesOrderId { get; set; }
+        public string? OrderNo { get; set; }
+        public long? CustomerId { get; set; }
+        public string? CustomerNo { get; set; }
+        public string? CustomerName { get; set; }
+        public DateTime? OrderDate { get; set; }
+        public int? OrderType { get; set; }
+        public string? OrderStatus { get; set; }
+        public string? FlowState { get; set; }
+        public DateTime? ReviewStartTime { get; set; }
+        public DateTime? ReviewEndTime { get; set; }
+        public decimal? ActualHours { get; set; }
+        public string? CalculationStatus { get; set; }
+    }
+}