11 Комити 69e87d10b4 ... 8ce9bca054

Аутор SHA1 Порука Датум
  YY968XX 8ce9bca054 fix(flow): block generic approval bypass for S5 IQC tasks пре 1 месец
  YY968XX 6aee5cd2e7 feat(s5): migrate IQC inspection detail to independent page пре 1 месец
  YY968XX f2dc6b9ea2 chore(server): close S5 IQC item result API version пре 1 месец
  YY968XX 33765dd817 feat(s8): integrate product design stage KPI пре 1 месец
  YY968XX a049b539d5 feat(s5): add IQC inspection detail editing UI пре 1 месец
  YY968XX 3b75144b26 feat(s8): add daily plan release stage authority пре 1 месец
  YY968XX 23cb5126ca feat(s5): add IQC item result API and submit validation пре 1 месец
  YY968XX 919c0d9364 feat(s8): integrate real order archive read model пре 1 месец
  YY968XX f342cb3faf feat(s5): snapshot IQC inspection items on bill generation | server 1.0.530 пре 1 месец
  YY968XX 85b3c8ea85 feat(s5): add IQC inspection item persistence foundation пре 1 месец
  YY968XX 10309cb0ca feat(s8): 订单评审实际耗时接入数据中台链路 пре 1 месец
55 измењених фајлова са 5570 додато и 554 уклоњено
  1. 1 1
      Web/package.json
  2. 12 1
      Web/src/stores/orderExecution.ts
  3. 83 2
      Web/src/views/aidop/s5/api/iqcInspBill.ts
  4. 6 5
      Web/src/views/aidop/s5/api/iqcInspBillFlow.ts
  5. 58 0
      Web/src/views/aidop/s5/api/iqcInspection.ts
  6. 0 358
      Web/src/views/aidop/s5/iqc/components/IqcInspBillDetailDrawer.vue
  7. 500 0
      Web/src/views/aidop/s5/iqc/components/IqcInspBillItemPanel.vue
  8. 580 0
      Web/src/views/aidop/s5/iqc/iqcInspBillDetail.vue
  9. 14 138
      Web/src/views/aidop/s5/iqc/iqcTaskList.vue
  10. 32 4
      Web/src/views/aidop/s8/api/s8OrderFlowDomainApi.ts
  11. 86 0
      Web/src/views/aidop/s8/api/s8OrderReviewKpiApi.ts
  12. 276 0
      Web/src/views/aidop/s8/monitoring/OrderReviewActualHoursPage.vue
  13. 5 1
      Web/src/views/aidop/s8/monitoring/components/order-execution/ChainStageFilterBar.vue
  14. 7 1
      Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderCard.vue
  15. 19 2
      Web/src/views/aidop/s8/monitoring/components/order-execution/OrderExecutionOrderList.vue
  16. 23 1
      Web/src/views/aidop/s8/monitoring/components/order-execution/statusMapping.ts
  17. 2 0
      Web/src/views/aidop/s8/monitoring/data/order-execution/builder.ts
  18. 19 6
      Web/src/views/aidop/s8/monitoring/data/order-execution/domainMapper.ts
  19. 19 3
      Web/src/views/aidop/s8/monitoring/data/order-execution/types.ts
  20. 18 0
      Web/src/views/approvalFlow/center/components/PendingList.vue
  21. 40 4
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  22. 153 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.527.sql
  23. 59 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.527.verify.sql
  24. 25 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.528.sql
  25. 23 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.528.verify.sql
  26. 139 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.529.sql
  27. 129 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.529.verify.sql
  28. 81 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.sql
  29. 97 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.verify.sql
  30. 54 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.533.sql
  31. 123 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.533.verify.sql
  32. 43 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.535.sql
  33. 48 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.535.verify.sql
  34. 145 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S5IqcGenericApprovalGuardContractTests.cs
  35. 25 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderFlowController.cs
  36. 43 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8OrderReviewKpiController.cs
  37. 59 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8OrderFlowDtos.cs
  38. 94 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/OrderFlow/AdoS8OrderReviewKpiDtos.cs
  39. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspBillDto.cs
  40. 191 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspectionDto.cs
  41. 11 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcTaskEntryDto.cs
  42. 98 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillEditGate.cs
  43. 50 1
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs
  44. 141 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillItemReader.cs
  45. 21 1
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillService.cs
  46. 139 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionJudge.cs
  47. 434 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionService.cs
  48. 42 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcTaskEntryService.cs
  49. 613 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/ActionEventMdpSyncService.cs
  50. 147 14
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs
  51. 20 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs
  52. 18 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  53. 133 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8OrderReviewKpiService.cs
  54. 316 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8RealOrderArchiveService.cs
  55. 39 0
      server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.393",
+	"version": "2.4.398",
 	"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();

+ 83 - 2
Web/src/views/aidop/s5/api/iqcInspBill.ts

@@ -62,7 +62,8 @@ export function fetchIqcInspBillList(params: any) {
 // 约 35 展示字段 + 流程占位 bizType/bizId/bizCode。字典全 raw。id 不存在返回 null。
 export interface IqcInspBillDetail {
 	// A 基本信息
-	id?: number;
+	/** 后端 long → JSON 字符串(大 id 超出 JS 安全整数范围,禁止 Number 转换) */
+	id?: number | string;
 	billNo?: string;
 	sourceBillNo?: string;
 	billStatus?: string;
@@ -113,9 +114,89 @@ export interface IqcInspBillDetail {
 	bizId?: number;
 	bizCode?: string;
 	flowHint?: string;
+	// H 项目级检验结果(后端 Batch 3 纯加法;N2/N3/终态同样返回,只读与否由 canEdit 表达)
+	/** 项目实例(检规快照 + 项目级结果 + 逐样本);itemCount=0 时为空数组 */
+	lines?: IqcInspBillItemLine[];
+	/** 项目级结果汇总(后端实时聚合) */
+	lineSummary?: IqcInspBillLineSummary;
+	/** 当前用户此刻是否可编辑项目级结果。**后端唯一 authority**,前端不得按节点自行推断 */
+	canEdit?: boolean;
+}
+
+// ── S5 IQC 项目级检验结果(读契约,随 detail 一起返回)──
+// 派生/审计字段(sampleNumeric / judgement / judgementSource / inspectorId / inspectionTime /
+// lastResultBy / lastResultAt)全部由后端写入,前端只读展示,不得 author。
+export type IqcItemResultType = 'NUMERIC' | 'NON_NUMERIC';
+
+// ⚠️ id 类 字段声明为 number | string:后端 long 超出 JS 安全整数范围,
+// Admin.NET 序列化为 JSON 字符串(实测 detail 返回 "847745575465029")。按 number 声明会触发
+// Vue prop 类型校验告警,且等值比较会踩隐式转换。
+
+/** 逐样本实测值(仅 NUMERIC 使用;NON_NUMERIC 恒为空数组) */
+export interface IqcInspBillItemSample {
+	id?: number | string;
+	sampleIndex?: number;
+	/** 检验员录入原文(后端原样保留) */
+	sampleValueRaw?: string | null;
+	/** 后端解析值;不可解析 = null */
+	sampleNumeric?: number | null;
+	/** PASS / FAIL / INVALID,后端唯一写入方 */
+	judgement?: string | null;
+	remark?: string | null;
+	inspectorId?: number | null;
+	inspectionTime?: string | null;
+}
+
+/** 检验单项目实例(检规快照不可变 + 项目级结果) */
+export interface IqcInspBillItemLine {
+	/** 项目实例主键(save-detail 回传用;后端 long → JSON 字符串) */
+	id: number | string;
+	/** 展示序号(快照自检规,可空可重复,不作键) */
+	seq?: number | null;
+	// 来源追溯(仅 lineage,不展示)
+	sourceSpecId?: number;
+	sourceSpecItemId?: number;
+	// 检规快照(只读)
+	inspectionItem?: string | null;
+	inspectionStandard?: string | null;
+	methodTool?: string | null;
+	imageCategory?: string | null;
+	samplingScheme?: string | null;
+	specRemark?: string | null;
+	upperLimit?: string | null;
+	lowerLimit?: string | null;
+	// 项目级结果
+	/** NUMERIC / NON_NUMERIC;null = 未定(合法初态) */
+	resultType?: IqcItemResultType | null;
+	/** PASS / FAIL / INVALID;null = 未录 */
+	itemJudgement?: string | null;
+	/** AUTO / MANUAL */
+	judgementSource?: string | null;
+	/** NON_NUMERIC 测量真源之一;NUMERIC 恒为 null */
+	qualifiedQty?: number | null;
+	/** NON_NUMERIC 测量真源之一;NUMERIC 恒为 null */
+	unqualifiedQty?: number | null;
+	resultRemark?: string | null;
+	lastResultBy?: number | null;
+	lastResultAt?: string | null;
+	/** NUMERIC 测量真源 */
+	samples?: IqcInspBillItemSample[];
+}
+
+/** 项目级结果汇总(后端实时聚合,不落表);itemCount=0 ⇒ LEGACY MODE */
+export interface IqcInspBillLineSummary {
+	itemCount?: number;
+	/** 已给出 PASS/FAIL 的项目数(可提交口径) */
+	judgedCount?: number;
+	passCount?: number;
+	failCount?: number;
+	invalidCount?: number;
+	unjudgedCount?: number;
 }
 
-export function fetchIqcInspBillDetail(id: number) {
+// id 放宽为 number | string:detail 路由用 path param(字符串),且库中存在
+// 101991258985070726 这类 > Number.MAX_SAFE_INTEGER 的检验单 id,Number() 会丢精度。
+export function fetchIqcInspBillDetail(id: number | string) {
 	return service
 		.get<IqcInspBillDetail | null>('/api/S5IqcInspBill/detail', { params: { id } })
 		.then((r) => r.data);

+ 6 - 5
Web/src/views/aidop/s5/api/iqcInspBillFlow.ts

@@ -2,6 +2,7 @@ import service from '/@/utils/request';
 import { withAidopTenantParams } from '../../api/aidopTenant';
 
 // ── S5 来料检验单 检验员+主管最小审批流(写接口,正常鉴权)──
+// id 类一律 number | string:详情页走 path param,且大 id 超出 JS 安全整数范围(见 iqcInspBill.ts)
 // 后端 api/S5IqcInspBillFlow。BizType=S5_IQC_INSPBILL。不做 SQE/仓储/入库/导出。
 export interface IqcFlowState {
 	bizType?: string;
@@ -26,7 +27,7 @@ export interface IqcFlowState {
 }
 
 export interface IqcSqeDispositionPayload {
-	id: number;
+	id: number | string;
 	dispositionType: number;
 	dispositionQty?: number | null;
 	concessionQty?: number | null;
@@ -39,7 +40,7 @@ export interface IqcSqeDispositionPayload {
 }
 
 export interface IqcSubmitResultPayload {
-	id: number;
+	id: number | string;
 	pd: number;
 	dhsl: number;
 	bhgsl: number;
@@ -47,7 +48,7 @@ export interface IqcSubmitResultPayload {
 	comment?: string;
 }
 
-export function fetchIqcInspBillFlowState(id: number) {
+export function fetchIqcInspBillFlowState(id: number | string) {
 	return service
 		.get<IqcFlowState>('/api/S5IqcInspBillFlow/state', { params: withAidopTenantParams({ id }) })
 		.then((r) => r.data);
@@ -57,11 +58,11 @@ export function submitIqcInspBillResult(payload: IqcSubmitResultPayload) {
 	return service.post<IqcFlowState>('/api/S5IqcInspBillFlow/submit-result', payload).then((r) => r.data);
 }
 
-export function approveIqcInspBill(id: number, comment?: string) {
+export function approveIqcInspBill(id: number | string, comment?: string) {
 	return service.post<IqcFlowState>('/api/S5IqcInspBillFlow/supervisor-approve', { id, comment }).then((r) => r.data);
 }
 
-export function rejectIqcInspBill(id: number, comment: string) {
+export function rejectIqcInspBill(id: number | string, comment: string) {
 	return service.post<IqcFlowState>('/api/S5IqcInspBillFlow/supervisor-reject', { id, comment }).then((r) => r.data);
 }
 

+ 58 - 0
Web/src/views/aidop/s5/api/iqcInspection.ts

@@ -0,0 +1,58 @@
+import service from '/@/utils/request';
+import type { IqcInspBillItemLine, IqcInspBillLineSummary, IqcItemResultType } from './iqcInspBill';
+
+// ── S5 IQC 项目级检验结果录入(写接口)──
+// 后端 api/S5IqcInspection/save-detail:只保存测量事实与备注,**不推进流程**(提交仍走 S5IqcInspBillFlow)。
+//
+// 前端一律不得 author 的字段(传了后端也忽略/拒绝):
+//   tenantId · itemJudgement · judgementSource · sampleNumeric · sample.judgement
+//   · inspectorId · inspectionTime · lastResultBy · lastResultAt
+// 判定由后端依据「快照冻结的上下限 + 实录测量事实」唯一计算,前端不复算。
+
+/** 单个样本入参(仅 NUMERIC)。录入原文必填——空值语义 = 该样本不存在,应从数组中移除 */
+export interface IqcSaveDetailSamplePayload {
+	sampleIndex: number;
+	sampleValueRaw: string;
+	remark?: string | null;
+}
+
+/** 单个项目的结果入参 */
+export interface IqcSaveDetailItemPayload {
+	/** 后端 long → JSON 字符串,原样回传即可 */
+	itemId: number | string;
+	/** 不传 = 沿用库中现值;与库中现值不同即类型切换,后端会清空该项目全部旧测量事实 */
+	resultType?: IqcItemResultType | null;
+	/** NON_NUMERIC 测量真源;NUMERIC 携带 → 400 */
+	qualifiedQty?: number | null;
+	/** NON_NUMERIC 测量真源;NUMERIC 携带 → 400 */
+	unqualifiedQty?: number | null;
+	resultRemark?: string | null;
+	/** 人工判定,仅 PASS / FAIL(不得人工设 INVALID);不传 ⇒ 后端按测量真源 AUTO 重算 */
+	manualJudgement?: 'PASS' | 'FAIL' | null;
+	/** NUMERIC 测量真源,**该项目的完整新集合**(未出现的旧样本会被删除);NON_NUMERIC 携带 → 400 */
+	samples?: IqcSaveDetailSamplePayload[];
+}
+
+/** save-detail 入参。item 级增量:未出现在 items 中的项目完全不动 */
+export interface IqcSaveDetailPayload {
+	billId: number | string;
+	items: IqcSaveDetailItemPayload[];
+}
+
+/** save-detail 出参 */
+export interface IqcSaveDetailResult {
+	ok?: boolean;
+	savedItems?: number;
+	savedSamples?: number;
+	deletedSamples?: number;
+	/** 因类型切换而清空过测量事实的项目 id */
+	clearedByTypeChange?: number[];
+	/** 因测量事实变更而被作废的旧 MANUAL 判定所属项目 id */
+	manualInvalidated?: number[];
+	lineSummary?: IqcInspBillLineSummary;
+	items?: IqcInspBillItemLine[];
+}
+
+export function saveIqcInspDetail(payload: IqcSaveDetailPayload) {
+	return service.post<IqcSaveDetailResult>('/api/S5IqcInspection/save-detail', payload).then((r) => r.data);
+}

+ 0 - 358
Web/src/views/aidop/s5/iqc/components/IqcInspBillDetailDrawer.vue

@@ -1,358 +0,0 @@
-<template>
-	<el-drawer v-model="innerVisible" :size="620" direction="rtl" @closed="onClosed">
-		<template #header>
-			<div class="drawer-title">
-				<span class="t1">来料检验单详情</span>
-				<span class="t2">{{ detail?.billNo || '-' }}</span>
-			</div>
-		</template>
-
-		<div v-loading="loading" class="detail-body">
-			<template v-if="detail">
-				<el-descriptions title="基本信息" :column="2" border size="small">
-					<el-descriptions-item label="检验单号">{{ dash(detail.billNo) }}</el-descriptions-item>
-					<el-descriptions-item label="来源单号">{{ dash(detail.sourceBillNo) }}</el-descriptions-item>
-					<el-descriptions-item label="单据状态">{{ dash(detail.billStatus) }}</el-descriptions-item>
-					<el-descriptions-item label="组织">{{ dash(detail.orgId) }}</el-descriptions-item>
-					<el-descriptions-item label="创建时间">{{ fmtDateTime(detail.createTime) }}</el-descriptions-item>
-					<el-descriptions-item label="更新时间">{{ fmtDateTime(detail.updateTime) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="物料与供应商" :column="2" border size="small" class="mt12">
-					<el-descriptions-item label="物料编码">{{ dash(detail.materialCode) }}</el-descriptions-item>
-					<el-descriptions-item label="物料名称">{{ dash(detail.materialName) }}</el-descriptions-item>
-					<el-descriptions-item label="规格型号">{{ dash(detail.spec) }}</el-descriptions-item>
-					<el-descriptions-item label="供应商">{{ dash(detail.supplierName) }}</el-descriptions-item>
-					<el-descriptions-item label="批次">{{ dash(detail.lotNo) }}</el-descriptions-item>
-					<el-descriptions-item label="检规编号">{{ dash(detail.inspectionSpecNo) }}</el-descriptions-item>
-					<el-descriptions-item label="检规版本">{{ dash(detail.inspectionSpecVersion) }}</el-descriptions-item>
-					<el-descriptions-item label="检规生效日期">{{ dash(detail.inspectionSpecEffectiveDate) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="检验数量" :column="2" border size="small" class="mt12">
-					<el-descriptions-item label="报检数量">{{ dashNum(detail.reportQty) }}</el-descriptions-item>
-					<el-descriptions-item label="检验数量">{{ dashNum(detail.inspectQty) }}</el-descriptions-item>
-					<el-descriptions-item label="样本数量">{{ dashNum(detail.sampleQty) }}</el-descriptions-item>
-					<el-descriptions-item label="测试数量">{{ dashNum(detail.testQty) }}</el-descriptions-item>
-					<el-descriptions-item label="合格数量">{{ dashNum(detail.qualifiedQty) }}</el-descriptions-item>
-					<el-descriptions-item label="不合格数量">{{ dashNum(detail.unqualifiedQty) }}</el-descriptions-item>
-					<el-descriptions-item label="破坏数量">{{ dashNum(detail.destructiveQty) }}</el-descriptions-item>
-					<el-descriptions-item label="留样数量">{{ dashNum(detail.retainSampleQty) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="检验执行" :column="2" border size="small" class="mt12">
-					<el-descriptions-item label="检验员">{{ dash(detail.inspector) }}</el-descriptions-item>
-					<el-descriptions-item label="检验人">{{ dash(detail.inspectorName) }}</el-descriptions-item>
-					<el-descriptions-item label="检验开始时间">{{ fmtDateTime(detail.inspectStartDate) }}</el-descriptions-item>
-					<el-descriptions-item label="检验结束时间">{{ fmtDateTime(detail.inspectEndDate) }}</el-descriptions-item>
-					<el-descriptions-item label="判定">{{ dash(detail.judgement) }}</el-descriptions-item>
-					<el-descriptions-item label="处理方式">{{ dash(detail.processMethod) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="不合格与偏差" :column="1" border size="small" class="mt12">
-					<el-descriptions-item label="不合格描述">
-						<span class="pre">{{ dash(detail.unqualifiedDescription) }}</span>
-					</el-descriptions-item>
-					<el-descriptions-item label="不良分类">{{ dash(detail.defectCategoryName) }}</el-descriptions-item>
-					<el-descriptions-item label="退货原因">
-						<span class="pre">{{ dash(detail.returnReason) }}</span>
-					</el-descriptions-item>
-					<el-descriptions-item label="偏差申请">{{ dash(detail.deviationApply) }}</el-descriptions-item>
-					<el-descriptions-item label="偏差说明">
-						<span class="pre">{{ dash(detail.deviationDescription) }}</span>
-					</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="审核信息(仅展示,不作为流程依据)" :column="2" border size="small" class="mt12">
-					<el-descriptions-item label="主管候选">{{ dash(detail.leader) }}</el-descriptions-item>
-					<el-descriptions-item label="审核人">{{ dash(detail.auditorId) }}</el-descriptions-item>
-					<el-descriptions-item label="审核时间">{{ fmtDateTime(detail.auditDate) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<el-descriptions title="流程信息" :column="2" border size="small" class="mt12" v-loading="flowLoading">
-					<el-descriptions-item label="BizType">{{ dash(detail.bizType) }}</el-descriptions-item>
-					<el-descriptions-item label="BizCode">{{ dash(detail.bizCode) }}</el-descriptions-item>
-					<el-descriptions-item label="流程状态">{{ dash(flowState?.flowStatus) }}</el-descriptions-item>
-					<el-descriptions-item label="当前节点">{{ dash(flowState?.currentNodeName) }}</el-descriptions-item>
-					<el-descriptions-item label="当前角色">{{ dash(flowState?.currentAssigneeRole) }}</el-descriptions-item>
-					<el-descriptions-item label="说明">{{ dash(flowState?.message) }}</el-descriptions-item>
-				</el-descriptions>
-
-				<!-- 检验员录入结果(仅 canSubmitResult 显示)-->
-				<div v-if="flowState?.canSubmitResult" class="flow-op mt12">
-					<div class="flow-op-title">检验员录入检验结果</div>
-					<el-form :model="resultForm" label-width="96px" size="small">
-						<el-form-item label="判定">
-							<el-select v-model="resultForm.pd" style="width: 180px" @change="onPdChange">
-								<el-option :value="0" label="合格" />
-								<el-option :value="1" label="不合格" />
-							</el-select>
-						</el-form-item>
-						<el-form-item label="合格数量">
-							<el-input-number v-model="resultForm.dhsl" :min="0" :controls="false" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="不合格数量">
-							<el-input-number v-model="resultForm.bhgsl" :min="0" :controls="false" :disabled="resultForm.pd === 0" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="处理方式" v-if="resultForm.pd === 1">
-							<el-select v-model="resultForm.clfs" style="width: 180px" clearable>
-								<el-option :value="0" label="让步接收" />
-								<el-option :value="1" label="挑选" />
-								<el-option :value="2" label="报废" />
-							</el-select>
-						</el-form-item>
-						<el-form-item label="备注">
-							<el-input v-model="resultForm.comment" type="textarea" :rows="2" style="width: 320px" />
-						</el-form-item>
-						<el-form-item>
-							<el-button type="primary" :loading="submitting" @click="onSubmitResult">提交检验结果</el-button>
-						</el-form-item>
-					</el-form>
-				</div>
-
-				<!-- 检验主管审核(仅 canSupervisorApprove/Reject 显示)-->
-				<div v-if="flowState?.canSupervisorApprove || flowState?.canSupervisorReject" class="flow-op mt12">
-					<div class="flow-op-title">检验主管审核</div>
-					<el-button type="success" :loading="submitting" @click="onApprove">通过</el-button>
-					<el-button type="warning" :loading="submitting" @click="onReject">退回</el-button>
-				</div>
-
-				<!-- SQE 处置(仅 canSqeSubmit 显示)-->
-				<div v-if="flowState?.canSqeSubmit" class="flow-op mt12">
-					<div class="flow-op-title">SQE 处置(不合格)</div>
-					<div class="sqe-hint">不合格数量 {{ dashNum(detail.unqualifiedQty) }} · 检验员处理方式(clfs) {{ dash(detail.processMethod) }}</div>
-					<el-form :model="sqeForm" label-width="96px" size="small" class="mt12">
-						<el-form-item label="处置方式">
-							<el-select v-model="sqeForm.dispositionType" style="width: 180px">
-								<el-option :value="0" label="让步接收" />
-								<el-option :value="1" label="挑选" />
-								<el-option :value="2" label="报废" />
-							</el-select>
-						</el-form-item>
-						<el-form-item label="处置数量">
-							<el-input-number v-model="sqeForm.dispositionQty" :min="0" :controls="false" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="让步数量">
-							<el-input-number v-model="sqeForm.concessionQty" :min="0" :controls="false" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="挑选数量">
-							<el-input-number v-model="sqeForm.selectionQty" :min="0" :controls="false" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="报废数量">
-							<el-input-number v-model="sqeForm.scrapQty" :min="0" :controls="false" style="width: 180px" />
-						</el-form-item>
-						<el-form-item label="处置意见">
-							<el-input v-model="sqeForm.dispositionOpinion" type="textarea" :rows="2" style="width: 320px" />
-						</el-form-item>
-						<el-form-item label="供应商回复">
-							<el-input v-model="sqeForm.supplierResponse" type="textarea" :rows="2" style="width: 320px" />
-						</el-form-item>
-						<el-form-item>
-							<el-button type="primary" :loading="submitting" @click="onSqeSubmit">提交 SQE 处置</el-button>
-						</el-form-item>
-					</el-form>
-				</div>
-			</template>
-			<el-empty v-else-if="!loading" description="暂无详情数据" />
-		</div>
-	</el-drawer>
-</template>
-
-<script setup lang="ts" name="IqcInspBillDetailDrawer">
-import { computed, reactive, watch } from 'vue';
-import { ElMessage, ElMessageBox } from 'element-plus';
-import type { IqcInspBillDetail } from '../../api/iqcInspBill';
-import type { IqcFlowState, IqcSubmitResultPayload, IqcSqeDispositionPayload } from '../../api/iqcInspBillFlow';
-
-const props = defineProps<{
-	modelValue: boolean;
-	detail: IqcInspBillDetail | null;
-	loading?: boolean;
-	flowState?: IqcFlowState | null;
-	flowLoading?: boolean;
-	submitting?: boolean;
-}>();
-const emit = defineEmits<{
-	(e: 'update:modelValue', v: boolean): void;
-	(e: 'closed'): void;
-	(e: 'submit-result', payload: IqcSubmitResultPayload): void;
-	(e: 'approve'): void;
-	(e: 'reject', comment: string): void;
-	(e: 'sqe-submit', payload: IqcSqeDispositionPayload): void;
-}>();
-
-const innerVisible = computed({
-	get: () => props.modelValue,
-	set: (v: boolean) => emit('update:modelValue', v),
-});
-
-// 检验员录入表单(只读详情批不含,本批新增,仅 canSubmitResult 时可见)
-const resultForm = reactive<{ pd: number; dhsl: number; bhgsl: number; clfs: number | null; comment: string }>({
-	pd: 0,
-	dhsl: 0,
-	bhgsl: 0,
-	clfs: null,
-	comment: '',
-});
-
-// SQE 处置表单(仅 canSqeSubmit 时可见)
-const sqeForm = reactive<{
-	dispositionType: number;
-	dispositionQty: number | null;
-	concessionQty: number | null;
-	selectionQty: number | null;
-	scrapQty: number | null;
-	dispositionOpinion: string;
-	supplierResponse: string;
-}>({
-	dispositionType: 0,
-	dispositionQty: null,
-	concessionQty: null,
-	selectionQty: null,
-	scrapQty: null,
-	dispositionOpinion: '',
-	supplierResponse: '',
-});
-
-// 每次打开 drawer 重置录入表单
-watch(
-	() => props.modelValue,
-	(v) => {
-		if (v) {
-			resultForm.pd = 0;
-			resultForm.dhsl = 0;
-			resultForm.bhgsl = 0;
-			resultForm.clfs = null;
-			resultForm.comment = '';
-			sqeForm.dispositionType = 0;
-			sqeForm.dispositionQty = null;
-			sqeForm.concessionQty = null;
-			sqeForm.selectionQty = null;
-			sqeForm.scrapQty = null;
-			sqeForm.dispositionOpinion = '';
-			sqeForm.supplierResponse = '';
-		}
-	}
-);
-
-function onSqeSubmit() {
-	if (sqeForm.dispositionType !== 0 && sqeForm.dispositionType !== 1 && sqeForm.dispositionType !== 2)
-		return ElMessage.error('请选择处置方式');
-	if (!sqeForm.dispositionOpinion || !sqeForm.dispositionOpinion.trim()) return ElMessage.error('请填写处置意见');
-	for (const q of [sqeForm.dispositionQty, sqeForm.concessionQty, sqeForm.selectionQty, sqeForm.scrapQty]) {
-		if (q !== null && q !== undefined && q < 0) return ElMessage.error('处置数量不能为负');
-	}
-	const id = props.detail?.id;
-	if (!id) return ElMessage.error('检验单 id 缺失');
-	emit('sqe-submit', {
-		id,
-		dispositionType: sqeForm.dispositionType,
-		dispositionQty: sqeForm.dispositionQty,
-		concessionQty: sqeForm.concessionQty,
-		selectionQty: sqeForm.selectionQty,
-		scrapQty: sqeForm.scrapQty,
-		dispositionOpinion: sqeForm.dispositionOpinion.trim(),
-		supplierResponse: sqeForm.supplierResponse || undefined,
-	});
-}
-
-function onPdChange() {
-	if (resultForm.pd === 0) {
-		resultForm.bhgsl = 0;
-		resultForm.clfs = null;
-	}
-}
-
-function onSubmitResult() {
-	if (resultForm.pd !== 0 && resultForm.pd !== 1) return ElMessage.error('请选择判定');
-	if (resultForm.dhsl < 0) return ElMessage.error('合格数量不能为负');
-	if (resultForm.bhgsl < 0) return ElMessage.error('不合格数量不能为负');
-	if (resultForm.pd === 0 && resultForm.bhgsl !== 0) return ElMessage.error('判定合格时不合格数量必须为 0');
-	if (resultForm.pd === 1 && resultForm.bhgsl <= 0) return ElMessage.error('判定不合格时不合格数量必须大于 0');
-	if (resultForm.pd === 1 && (resultForm.clfs === null || resultForm.clfs === undefined)) return ElMessage.error('判定不合格时请选择处理方式');
-	const id = props.detail?.id;
-	if (!id) return ElMessage.error('检验单 id 缺失');
-	emit('submit-result', {
-		id,
-		pd: resultForm.pd,
-		dhsl: resultForm.dhsl,
-		bhgsl: resultForm.bhgsl,
-		clfs: resultForm.pd === 1 ? resultForm.clfs : null,
-		comment: resultForm.comment,
-	});
-}
-
-function onApprove() {
-	emit('approve');
-}
-
-async function onReject() {
-	try {
-		const { value } = await ElMessageBox.prompt('请填写退回意见', '退回', {
-			confirmButtonText: '确定退回',
-			cancelButtonText: '取消',
-			inputType: 'textarea',
-			inputValidator: (v: string) => (v && v.trim() ? true : '退回意见不能为空'),
-		});
-		emit('reject', value.trim());
-	} catch {
-		/* 用户取消 */
-	}
-}
-
-function dash(v: unknown) {
-	if (v === null || v === undefined || v === '') return '-';
-	return String(v);
-}
-function dashNum(v?: number | null) {
-	if (v === null || v === undefined) return '-';
-	return String(v);
-}
-function fmtDateTime(v?: string | null) {
-	if (!v) return '-';
-	return String(v).replace('T', ' ').slice(0, 19);
-}
-function onClosed() {
-	emit('closed');
-}
-</script>
-
-<style scoped lang="scss">
-.drawer-title {
-	display: flex;
-	align-items: baseline;
-	gap: 10px;
-	.t1 {
-		font-weight: bold;
-		font-size: 16px;
-	}
-	.t2 {
-		color: var(--el-text-color-secondary);
-		font-size: 13px;
-	}
-}
-.detail-body {
-	min-height: 120px;
-}
-.mt12 {
-	margin-top: 12px;
-}
-.pre {
-	white-space: pre-wrap;
-	word-break: break-word;
-}
-.flow-op {
-	padding: 12px;
-	border: 1px solid var(--el-border-color-lighter);
-	border-radius: 4px;
-	background: var(--el-fill-color-light);
-}
-.flow-op-title {
-	font-weight: bold;
-	margin-bottom: 10px;
-}
-.sqe-hint {
-	color: var(--el-text-color-secondary);
-	font-size: 12px;
-}
-</style>

+ 500 - 0
Web/src/views/aidop/s5/iqc/components/IqcInspBillItemPanel.vue

@@ -0,0 +1,500 @@
+<template>
+	<div class="item-panel">
+		<div class="panel-head">
+			<span class="panel-title">检验项目明细</span>
+			<el-tag v-if="canEdit" type="success" size="small" effect="plain">可录入</el-tag>
+			<el-tag v-else type="info" size="small" effect="plain">只读(当前节点不允许修改检验明细)</el-tag>
+		</div>
+
+		<!-- LEGACY MODE:历史单 / 未命中检规 / 命中检规但无明细,三者同一语义 -->
+		<el-alert
+			v-if="itemCount === 0"
+			class="mt12"
+			type="info"
+			:closable="false"
+			show-icon
+			title="该检验单没有项目实例"
+			description="历史单、未命中检规、命中检规但检规无明细,均为此状态。此类检验单按整单结论录入(见上方「检验员录入检验结果」),不做项目级录入。"
+		/>
+
+		<template v-else>
+			<div class="summary mt12">
+				<el-tag size="small" type="info" effect="plain">项目数 {{ summaryVal.itemCount }}</el-tag>
+				<el-tag size="small" type="info" effect="plain">已判定 {{ summaryVal.judgedCount }}</el-tag>
+				<el-tag size="small" type="success" effect="plain">合格 {{ summaryVal.passCount }}</el-tag>
+				<el-tag size="small" type="danger" effect="plain">不合格 {{ summaryVal.failCount }}</el-tag>
+				<el-tag size="small" type="warning" effect="plain">无效 {{ summaryVal.invalidCount }}</el-tag>
+				<el-tag size="small" effect="plain">未录 {{ summaryVal.unjudgedCount }}</el-tag>
+			</div>
+
+			<el-table ref="tableRef" :data="lines" row-key="id" border stripe size="small" class="mt12">
+				<el-table-column type="expand">
+					<template #default="{ row }">
+						<div class="expand-body">
+							<el-descriptions :column="2" border size="small" title="检规快照(生成时冻结,不回查检规)">
+								<el-descriptions-item label="检验标准" :span="2">
+									<span class="pre">{{ snap(row.inspectionStandard) }}</span>
+								</el-descriptions-item>
+								<el-descriptions-item label="检验方法·工具" :span="2">
+									<span class="pre">{{ snap(row.methodTool) }}</span>
+								</el-descriptions-item>
+								<el-descriptions-item label="下限">{{ snap(row.lowerLimit) }}</el-descriptions-item>
+								<el-descriptions-item label="上限">{{ snap(row.upperLimit) }}</el-descriptions-item>
+								<el-descriptions-item label="抽样方案">{{ snap(row.samplingScheme) }}</el-descriptions-item>
+								<el-descriptions-item label="图片分类">{{ snap(row.imageCategory) }}</el-descriptions-item>
+								<el-descriptions-item label="检规备注" :span="2">
+									<span class="pre">{{ snap(row.specRemark) }}</span>
+								</el-descriptions-item>
+							</el-descriptions>
+
+							<div v-if="drafts[String(row.id)]" class="editor mt12">
+								<el-form label-width="104px" size="small" @submit.prevent>
+									<el-form-item label="录入类型">
+										<el-select
+											v-model="drafts[String(row.id)].resultType"
+											:disabled="!canEdit"
+											placeholder="请选择"
+											style="width: 150px"
+											@change="(v: any) => onTypeChange(row, v)"
+										>
+											<el-option value="NUMERIC" label="数值型" />
+											<el-option value="NON_NUMERIC" label="非数值型" />
+										</el-select>
+										<el-tag v-if="isTypeSwitching(row)" class="ml8" type="warning" size="small" effect="plain">
+											保存后将清空该项目原有测量事实
+										</el-tag>
+										<span v-else-if="!drafts[String(row.id)].resultType" class="hint ml8">未选择录入类型,无法录入测量结果</span>
+									</el-form-item>
+
+									<!-- NUMERIC:测量真源 = 逐样本实测值 -->
+									<template v-if="drafts[String(row.id)].resultType === 'NUMERIC'">
+										<el-form-item label="样本实测值">
+											<div class="sample-wrap">
+												<el-table :data="drafts[String(row.id)].samples" border size="small" class="sample-table">
+													<el-table-column label="样本" width="64" align="center">
+														<template #default="{ row: s }">{{ s.sampleIndex }}</template>
+													</el-table-column>
+													<el-table-column label="实测值" min-width="130">
+														<template #default="{ row: s }">
+															<el-input v-if="canEdit" v-model="s.sampleValueRaw" size="small" placeholder="录入实测值" />
+															<span v-else>{{ snap(s.sampleValueRaw) }}</span>
+														</template>
+													</el-table-column>
+													<el-table-column label="判定(已保存)" width="110" align="center">
+														<template #default="{ row: s }">
+															<el-tag :type="judgeTagType(savedSampleJudgement(row, s.sampleIndex))" size="small" effect="plain">
+																{{ judgeText(savedSampleJudgement(row, s.sampleIndex)) }}
+															</el-tag>
+														</template>
+													</el-table-column>
+													<el-table-column label="备注" min-width="120">
+														<template #default="{ row: s }">
+															<el-input v-if="canEdit" v-model="s.remark" size="small" />
+															<span v-else>{{ snap(s.remark) }}</span>
+														</template>
+													</el-table-column>
+													<el-table-column v-if="canEdit" label="操作" width="64" align="center">
+														<template #default="{ $index }">
+															<el-button type="danger" link @click="removeSample(row.id, $index)">删除</el-button>
+														</template>
+													</el-table-column>
+													<template #empty>
+														<span class="hint">暂无样本,判定为「未录」</span>
+													</template>
+												</el-table>
+												<div v-if="canEdit" class="mt8">
+													<el-button size="small" @click="addSample(row.id)">新增样本</el-button>
+													<span class="hint ml8">样本值为该项目的完整集合;判定由后端按快照上下限重算,保存后刷新</span>
+												</div>
+											</div>
+										</el-form-item>
+									</template>
+
+									<!-- NON_NUMERIC:测量真源 = 项目级合格/不合格数 -->
+									<template v-else-if="drafts[String(row.id)].resultType === 'NON_NUMERIC'">
+										<el-form-item label="合格数">
+											<el-input-number
+												v-model="drafts[String(row.id)].qualifiedQty"
+												:min="0"
+												:controls="false"
+												:value-on-clear="null"
+												:disabled="!canEdit"
+												style="width: 150px"
+											/>
+										</el-form-item>
+										<el-form-item label="不合格数">
+											<el-input-number
+												v-model="drafts[String(row.id)].unqualifiedQty"
+												:min="0"
+												:controls="false"
+												:value-on-clear="null"
+												:disabled="!canEdit"
+												style="width: 150px"
+											/>
+											<span class="hint ml8">合格数与不合格数均需录入才会形成判定</span>
+										</el-form-item>
+									</template>
+
+									<el-form-item label="人工判定">
+										<el-select v-model="drafts[String(row.id)].manualJudgement" :disabled="!canEdit" style="width: 150px">
+											<el-option value="AUTO" label="自动判定" />
+											<el-option value="PASS" label="人工判合格" />
+											<el-option value="FAIL" label="人工判不合格" />
+										</el-select>
+										<span class="hint ml8">人工判定仅合格/不合格;「无效」为系统派生结论,不可人工设置</span>
+									</el-form-item>
+
+									<el-form-item label="结果备注">
+										<el-input
+											v-model="drafts[String(row.id)].resultRemark"
+											type="textarea"
+											:rows="2"
+											:disabled="!canEdit"
+											style="width: 360px"
+										/>
+									</el-form-item>
+
+									<el-form-item label="最近录入">
+										<span class="hint">
+											{{ dash(row.lastResultBy) }} · {{ fmtDateTime(row.lastResultAt) }}
+										</span>
+									</el-form-item>
+
+									<el-form-item v-if="canEdit">
+										<el-button type="primary" :loading="isSaving(row)" @click="onSaveItem(row)">保存本项目</el-button>
+									</el-form-item>
+								</el-form>
+							</div>
+						</div>
+					</template>
+				</el-table-column>
+
+				<el-table-column label="序号" width="62" align="center">
+					<template #default="{ row }">{{ dash(row.seq) }}</template>
+				</el-table-column>
+				<el-table-column label="检验项目" min-width="120" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.inspectionItem) }}</template>
+				</el-table-column>
+				<el-table-column label="检验标准" min-width="120" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.inspectionStandard) }}</template>
+				</el-table-column>
+				<el-table-column label="检验方法·工具" min-width="130" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.methodTool) }}</template>
+				</el-table-column>
+				<el-table-column label="限值" width="120" align="center">
+					<template #default="{ row }">{{ limitText(row) }}</template>
+				</el-table-column>
+				<el-table-column label="录入类型" width="86" align="center">
+					<template #default="{ row }">{{ resultTypeText(row.resultType) }}</template>
+				</el-table-column>
+				<el-table-column label="判定" width="104" align="center">
+					<template #default="{ row }">
+						<el-tag :type="judgeTagType(row.itemJudgement)" size="small" effect="plain">{{ judgeText(row.itemJudgement) }}</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column label="来源" width="62" align="center">
+					<template #default="{ row }">{{ sourceText(row.judgementSource) }}</template>
+				</el-table-column>
+				<el-table-column label="结果·测量" min-width="130">
+					<template #default="{ row }">{{ measureText(row) }}</template>
+				</el-table-column>
+				<el-table-column label="结果备注" min-width="104" show-overflow-tooltip>
+					<template #default="{ row }">{{ snap(row.resultRemark) }}</template>
+				</el-table-column>
+				<el-table-column label="操作" width="78" align="center" fixed="right">
+					<template #default="{ row }">
+						<el-button type="primary" link @click="toggleExpand(row)">{{ canEdit ? '录入' : '查看' }}</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+		</template>
+	</div>
+</template>
+
+<script setup lang="ts" name="IqcInspBillItemPanel">
+import { computed, ref, watch } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import type { IqcInspBillItemLine, IqcInspBillLineSummary, IqcItemResultType } from '../../api/iqcInspBill';
+import type { IqcSaveDetailItemPayload, IqcSaveDetailPayload } from '../../api/iqcInspection';
+
+// 项目级检验结果录入面板(嵌在来料检验单详情抽屉内,不单独路由)。
+//
+// 【授权】canEdit 由后端 detail 给出,是**唯一** UI 编辑 authority;
+//        本组件不按 currentNodeCode 自行推断是否可编辑。
+// 【判定】一律由后端依据「快照冻结的上下限 + 实录测量事实」计算;
+//        本组件只展示后端已保存的判定,**不做任何前端复算/预判**。
+// id 类一律 number | string:后端 long 序列化为 JSON 字符串(见 api/iqcInspBill.ts 注释)
+const props = defineProps<{
+	billId?: number | string | null;
+	lines?: IqcInspBillItemLine[];
+	summary?: IqcInspBillLineSummary | null;
+	canEdit?: boolean;
+	savingItemId?: number | string | null;
+}>();
+const emit = defineEmits<{
+	(e: 'save', payload: IqcSaveDetailPayload, itemId: number | string): void;
+}>();
+
+const tableRef = ref<any>(null);
+
+interface SampleDraft {
+	sampleIndex: number;
+	sampleValueRaw: string;
+	remark: string;
+}
+interface ItemDraft {
+	resultType: IqcItemResultType | null;
+	qualifiedQty: number | null;
+	unqualifiedQty: number | null;
+	resultRemark: string;
+	/** AUTO = 不提交 manualJudgement,由后端按测量真源重算 */
+	manualJudgement: 'AUTO' | 'PASS' | 'FAIL';
+	samples: SampleDraft[];
+}
+
+// 以 String(id) 作 key:id 可能是字符串形态的 long
+const drafts = ref<Record<string, ItemDraft>>({});
+
+const lines = computed<IqcInspBillItemLine[]>(() => props.lines ?? []);
+const itemCount = computed(() => props.summary?.itemCount ?? lines.value.length);
+const canEdit = computed(() => props.canEdit === true);
+const summaryVal = computed(() => ({
+	itemCount: props.summary?.itemCount ?? 0,
+	judgedCount: props.summary?.judgedCount ?? 0,
+	passCount: props.summary?.passCount ?? 0,
+	failCount: props.summary?.failCount ?? 0,
+	invalidCount: props.summary?.invalidCount ?? 0,
+	unjudgedCount: props.summary?.unjudgedCount ?? 0,
+}));
+
+// 每次 detail 重新拉取(打开 / 保存后 / 流程动作后)都按后端最新值重建草稿,
+// 避免旧的本地编辑残留覆盖服务端事实。
+watch(
+	() => props.lines,
+	(list) => {
+		const next: Record<string, ItemDraft> = {};
+		for (const l of list ?? []) {
+			next[String(l.id)] = {
+				resultType: (l.resultType as IqcItemResultType) ?? null,
+				qualifiedQty: l.qualifiedQty ?? null,
+				unqualifiedQty: l.unqualifiedQty ?? null,
+				resultRemark: l.resultRemark ?? '',
+				manualJudgement:
+					l.judgementSource === 'MANUAL' && (l.itemJudgement === 'PASS' || l.itemJudgement === 'FAIL')
+						? (l.itemJudgement as 'PASS' | 'FAIL')
+						: 'AUTO',
+				samples: (l.samples ?? []).map((s) => ({
+					sampleIndex: s.sampleIndex ?? 0,
+					sampleValueRaw: s.sampleValueRaw ?? '',
+					remark: s.remark ?? '',
+				})),
+			};
+		}
+		drafts.value = next;
+	},
+	{ immediate: true }
+);
+
+/** 单项保存中(id 可能是字符串形态的 long,统一按字符串比较) */
+function isSaving(row: IqcInspBillItemLine) {
+	const s = props.savingItemId;
+	return s !== null && s !== undefined && String(s) === String(row.id);
+}
+
+function toggleExpand(row: IqcInspBillItemLine) {
+	tableRef.value?.toggleRowExpansion(row);
+}
+
+/** 草稿类型与库中现值不同 ⇒ 保存时后端会清空该项目原有测量事实 */
+function isTypeSwitching(row: IqcInspBillItemLine) {
+	const d = drafts.value[String(row.id)];
+	if (!d || !d.resultType) return false;
+	return d.resultType !== (row.resultType ?? null);
+}
+
+/** 该项目在**服务端**是否已存在测量事实(样本行 或 计数真源) */
+function hasSavedFacts(row: IqcInspBillItemLine) {
+	if ((row.samples?.length ?? 0) > 0) return true;
+	if (row.qualifiedQty !== null && row.qualifiedQty !== undefined) return true;
+	return row.unqualifiedQty !== null && row.unqualifiedQty !== undefined;
+}
+
+async function onTypeChange(row: IqcInspBillItemLine, next: IqcItemResultType) {
+	const d = drafts.value[String(row.id)];
+	if (!d) return;
+	const dbType = row.resultType ?? null;
+	if (next === dbType) return; // 切回库中现值,无破坏性
+	if (!hasSavedFacts(row)) return; // 尚无已保存的测量事实,无需确认
+	try {
+		await ElMessageBox.confirm(
+			dbType === 'NUMERIC'
+				? '该项目已录入样本实测值。切换为「非数值型」并保存后,这些样本将被全部清空,判定随之重算。是否继续?'
+				: '该项目已录入合格/不合格数。切换为「数值型」并保存后,这些计数将被清空,判定随之重算。是否继续?',
+			'确认切换录入类型',
+			{ type: 'warning', confirmButtonText: '继续切换', cancelButtonText: '取消' }
+		);
+	} catch {
+		d.resultType = dbType; // 取消 → 回退到库中现值
+	}
+}
+
+function addSample(itemId: number | string) {
+	const d = drafts.value[String(itemId)];
+	if (!d) return;
+	const maxIdx = d.samples.reduce((m, s) => (s.sampleIndex > m ? s.sampleIndex : m), 0);
+	d.samples.push({ sampleIndex: maxIdx + 1, sampleValueRaw: '', remark: '' });
+}
+
+function removeSample(itemId: number | string, index: number) {
+	const d = drafts.value[String(itemId)];
+	if (!d) return;
+	d.samples.splice(index, 1);
+}
+
+function onSaveItem(row: IqcInspBillItemLine) {
+	const billId = props.billId;
+	if (!billId) return ElMessage.error('检验单 id 缺失');
+	const d = drafts.value[String(row.id)];
+	if (!d) return ElMessage.error('检验项目数据缺失,请重新打开详情');
+
+	const item: IqcSaveDetailItemPayload = { itemId: row.id };
+	if (d.resultType) item.resultType = d.resultType;
+	item.resultRemark = d.resultRemark && d.resultRemark.trim() ? d.resultRemark.trim() : null;
+	if (d.manualJudgement !== 'AUTO') item.manualJudgement = d.manualJudgement;
+
+	if (d.resultType === 'NUMERIC') {
+		const seen = new Set<number>();
+		for (const s of d.samples) {
+			if (!s.sampleValueRaw || !s.sampleValueRaw.trim())
+				return ElMessage.error(`样本 ${s.sampleIndex} 未填实测值(如需删除请点该行「删除」)`);
+			if (s.sampleIndex < 1) return ElMessage.error('样本序号必须大于 0');
+			if (seen.has(s.sampleIndex)) return ElMessage.error(`样本序号 ${s.sampleIndex} 重复`);
+			seen.add(s.sampleIndex);
+		}
+		item.samples = d.samples.map((s) => ({
+			sampleIndex: s.sampleIndex,
+			sampleValueRaw: s.sampleValueRaw.trim(),
+			remark: s.remark && s.remark.trim() ? s.remark.trim() : null,
+		}));
+	} else if (d.resultType === 'NON_NUMERIC') {
+		item.qualifiedQty = d.qualifiedQty;
+		item.unqualifiedQty = d.unqualifiedQty;
+	}
+
+	emit('save', { billId, items: [item] }, row.id);
+}
+
+// ───────────────────────── 展示辅助(只格式化,不判定) ─────────────────────────
+
+/** 取**已保存**的样本判定(后端唯一写入方)。草稿里改过的值在保存前不重算,刻意保持 stale 并在列头标注 */
+function savedSampleJudgement(row: IqcInspBillItemLine, sampleIndex: number) {
+	return row.samples?.find((s) => s.sampleIndex === sampleIndex)?.judgement ?? null;
+}
+
+function judgeText(j?: string | null) {
+	if (j === 'PASS') return '合格';
+	if (j === 'FAIL') return '不合格';
+	if (j === 'INVALID') return '无效';
+	return '未录';
+}
+function judgeTagType(j?: string | null) {
+	if (j === 'PASS') return 'success';
+	if (j === 'FAIL') return 'danger';
+	if (j === 'INVALID') return 'warning';
+	return 'info';
+}
+function sourceText(s?: string | null) {
+	if (s === 'MANUAL') return '人工';
+	if (s === 'AUTO') return '自动';
+	return '-';
+}
+function resultTypeText(t?: string | null) {
+	if (t === 'NUMERIC') return '数值型';
+	if (t === 'NON_NUMERIC') return '非数值型';
+	return '未选';
+}
+function limitText(row: IqcInspBillItemLine) {
+	const lo = snap(row.lowerLimit);
+	const up = snap(row.upperLimit);
+	if (lo === '-' && up === '-') return '-';
+	return `${lo} ~ ${up}`;
+}
+function measureText(row: IqcInspBillItemLine) {
+	if (row.resultType === 'NUMERIC') {
+		const n = row.samples?.length ?? 0;
+		return n === 0 ? '样本 0 条' : `样本 ${n} 条`;
+	}
+	if (row.resultType === 'NON_NUMERIC') {
+		return `合格 ${dashNum(row.qualifiedQty)} / 不合格 ${dashNum(row.unqualifiedQty)}`;
+	}
+	return '-';
+}
+
+function dash(v: unknown) {
+	if (v === null || v === undefined || v === '') return '-';
+	return String(v);
+}
+function dashNum(v?: number | null) {
+	if (v === null || v === undefined) return '-';
+	return String(v);
+}
+/** 快照/文本字段展示:空值与脏写入的 'null'/'undefined' 字面量一律显示 '-' */
+function snap(v: unknown) {
+	if (v === null || v === undefined) return '-';
+	const s = String(v).trim();
+	if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'undefined') return '-';
+	return s;
+}
+function fmtDateTime(v?: string | null) {
+	if (!v) return '-';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+</script>
+
+<style scoped lang="scss">
+.item-panel {
+	padding: 12px;
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+}
+.panel-head {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+.panel-title {
+	font-weight: bold;
+}
+.summary {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+.expand-body {
+	padding: 8px 12px 4px;
+	background: var(--el-fill-color-lighter);
+}
+.sample-wrap {
+	width: 100%;
+}
+.sample-table {
+	width: 100%;
+}
+.hint {
+	color: var(--el-text-color-secondary);
+	font-size: 12px;
+}
+.pre {
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+.mt8 {
+	margin-top: 8px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.ml8 {
+	margin-left: 8px;
+}
+</style>

+ 580 - 0
Web/src/views/aidop/s5/iqc/iqcInspBillDetail.vue

@@ -0,0 +1,580 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="检验项目明细录入 → 提交 → 主管审核 → SQE 处置">
+		<!-- 顶部:返回 / 单号 / 流程状态 -->
+		<div class="page-head">
+			<el-button size="small" @click="goBack">← 返回</el-button>
+			<span class="bill-no">{{ detail?.billNo || (invalidId ? '-' : '加载中…') }}</span>
+			<el-tag v-if="flowState?.flowStatus" :type="flowTagType" size="small" effect="plain">
+				{{ flowState.flowStatus }}<template v-if="flowState.currentNodeName"> · {{ flowState.currentNodeName }}</template>
+			</el-tag>
+			<el-tag v-if="detail?.billStatus" size="small" type="info" effect="plain">{{ detail.billStatus }}</el-tag>
+		</div>
+
+		<!-- 非法路由参数:不发任何请求 -->
+		<el-result v-if="invalidId" icon="warning" title="检验单参数非法" :sub-title="`路由参数 id = ${rawId ?? '(空)'},无法加载检验单`">
+			<template #extra>
+				<el-button type="primary" @click="goList">返回来料检验任务列表</el-button>
+			</template>
+		</el-result>
+
+		<div v-else v-loading="loading" class="detail-body">
+			<template v-if="detail">
+				<el-descriptions title="基本信息" :column="4" border size="small">
+					<el-descriptions-item label="检验单号">{{ dash(detail.billNo) }}</el-descriptions-item>
+					<el-descriptions-item label="来源单号">{{ dash(detail.sourceBillNo) }}</el-descriptions-item>
+					<el-descriptions-item label="单据状态">{{ dash(detail.billStatus) }}</el-descriptions-item>
+					<el-descriptions-item label="组织">{{ dash(detail.orgId) }}</el-descriptions-item>
+					<el-descriptions-item label="创建时间">{{ fmtDateTime(detail.createTime) }}</el-descriptions-item>
+					<el-descriptions-item label="更新时间">{{ fmtDateTime(detail.updateTime) }}</el-descriptions-item>
+					<el-descriptions-item label="创建人">{{ dash(detail.createUserId) }}</el-descriptions-item>
+					<el-descriptions-item label="修改人">{{ dash(detail.updateUserId) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-descriptions title="物料与供应商" :column="4" border size="small" class="mt12">
+					<el-descriptions-item label="物料编码">{{ dash(detail.materialCode) }}</el-descriptions-item>
+					<el-descriptions-item label="物料名称">{{ dash(detail.materialName) }}</el-descriptions-item>
+					<el-descriptions-item label="规格型号">{{ dash(detail.spec) }}</el-descriptions-item>
+					<el-descriptions-item label="供应商">{{ dash(detail.supplierName) }}</el-descriptions-item>
+					<el-descriptions-item label="批次">{{ dash(detail.lotNo) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<!-- 检规信息:由原「物料与供应商」混合块拆出为独立区域 -->
+				<el-descriptions title="检规信息" :column="4" border size="small" class="mt12">
+					<el-descriptions-item label="检规编号">{{ dash(detail.inspectionSpecNo) }}</el-descriptions-item>
+					<el-descriptions-item label="检规版本">{{ dash(detail.inspectionSpecVersion) }}</el-descriptions-item>
+					<el-descriptions-item label="检规生效日期">{{ dash(detail.inspectionSpecEffectiveDate) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-descriptions title="检验数量" :column="4" border size="small" class="mt12">
+					<el-descriptions-item label="报检数量">{{ dashNum(detail.reportQty) }}</el-descriptions-item>
+					<el-descriptions-item label="检验数量">{{ dashNum(detail.inspectQty) }}</el-descriptions-item>
+					<el-descriptions-item label="样本数量">{{ dashNum(detail.sampleQty) }}</el-descriptions-item>
+					<el-descriptions-item label="测试数量">{{ dashNum(detail.testQty) }}</el-descriptions-item>
+					<el-descriptions-item label="合格数量">{{ dashNum(detail.qualifiedQty) }}</el-descriptions-item>
+					<el-descriptions-item label="不合格数量">{{ dashNum(detail.unqualifiedQty) }}</el-descriptions-item>
+					<el-descriptions-item label="破坏数量">{{ dashNum(detail.destructiveQty) }}</el-descriptions-item>
+					<el-descriptions-item label="留样数量">{{ dashNum(detail.retainSampleQty) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-descriptions title="检验执行" :column="4" border size="small" class="mt12">
+					<el-descriptions-item label="检验员">{{ dash(detail.inspector) }}</el-descriptions-item>
+					<el-descriptions-item label="检验人">{{ dash(detail.inspectorName) }}</el-descriptions-item>
+					<el-descriptions-item label="检验开始时间">{{ fmtDateTime(detail.inspectStartDate) }}</el-descriptions-item>
+					<el-descriptions-item label="检验结束时间">{{ fmtDateTime(detail.inspectEndDate) }}</el-descriptions-item>
+					<el-descriptions-item label="判定">{{ dash(detail.judgement) }}</el-descriptions-item>
+					<el-descriptions-item label="处理方式">{{ dash(detail.processMethod) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-descriptions title="不合格与偏差" :column="2" border size="small" class="mt12">
+					<el-descriptions-item label="不合格描述" :span="2">
+						<span class="pre">{{ dash(detail.unqualifiedDescription) }}</span>
+					</el-descriptions-item>
+					<el-descriptions-item label="不良分类">{{ dash(detail.defectCategoryName) }}</el-descriptions-item>
+					<el-descriptions-item label="偏差申请">{{ dash(detail.deviationApply) }}</el-descriptions-item>
+					<el-descriptions-item label="退货原因" :span="2">
+						<span class="pre">{{ dash(detail.returnReason) }}</span>
+					</el-descriptions-item>
+					<el-descriptions-item label="偏差说明" :span="2">
+						<span class="pre">{{ dash(detail.deviationDescription) }}</span>
+					</el-descriptions-item>
+				</el-descriptions>
+
+				<!-- 项目级检验结果(后端 lines/lineSummary/canEdit 驱动;canEdit 为唯一编辑 authority)-->
+				<IqcInspBillItemPanel
+					class="mt12"
+					:bill-id="detail.id"
+					:lines="detail.lines"
+					:summary="detail.lineSummary"
+					:can-edit="detail.canEdit === true"
+					:saving-item-id="savingItemId"
+					@save="onSaveItemDetail"
+				/>
+
+				<el-descriptions title="审核信息(仅展示,不作为流程依据)" :column="4" border size="small" class="mt12">
+					<el-descriptions-item label="主管候选">{{ dash(detail.leader) }}</el-descriptions-item>
+					<el-descriptions-item label="审核人">{{ dash(detail.auditorId) }}</el-descriptions-item>
+					<el-descriptions-item label="审核时间">{{ fmtDateTime(detail.auditDate) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<el-descriptions title="流程信息" :column="4" border size="small" class="mt12" v-loading="flowLoading">
+					<el-descriptions-item label="BizType">{{ dash(detail.bizType) }}</el-descriptions-item>
+					<el-descriptions-item label="BizCode">{{ dash(detail.bizCode) }}</el-descriptions-item>
+					<el-descriptions-item label="流程状态">{{ dash(flowState?.flowStatus) }}</el-descriptions-item>
+					<el-descriptions-item label="当前节点">{{ dash(flowState?.currentNodeName) }}</el-descriptions-item>
+					<el-descriptions-item label="当前角色">{{ dash(flowState?.currentAssigneeRole) }}</el-descriptions-item>
+					<el-descriptions-item label="说明" :span="3">{{ dash(flowState?.message) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<!-- 检验员录入结果(仅 canSubmitResult 显示)-->
+				<div v-if="flowState?.canSubmitResult" class="flow-op mt12">
+					<div class="flow-op-title">检验员录入检验结果</div>
+					<el-form :model="resultForm" label-width="96px" size="small" @submit.prevent>
+						<el-form-item label="判定">
+							<el-select v-model="resultForm.pd" style="width: 180px" @change="onPdChange">
+								<el-option :value="0" label="合格" />
+								<el-option :value="1" label="不合格" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="合格数量">
+							<el-input-number v-model="resultForm.dhsl" :min="0" :controls="false" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="不合格数量">
+							<el-input-number v-model="resultForm.bhgsl" :min="0" :controls="false" :disabled="resultForm.pd === 0" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="处理方式" v-if="resultForm.pd === 1">
+							<el-select v-model="resultForm.clfs" style="width: 180px" clearable>
+								<el-option :value="0" label="让步接收" />
+								<el-option :value="1" label="挑选" />
+								<el-option :value="2" label="报废" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="备注">
+							<el-input v-model="resultForm.comment" type="textarea" :rows="2" style="width: 420px" />
+						</el-form-item>
+						<el-form-item>
+							<el-button type="primary" :loading="flowSubmitting" @click="onSubmitResult">提交检验结果</el-button>
+						</el-form-item>
+					</el-form>
+				</div>
+
+				<!-- 检验主管审核(仅 canSupervisorApprove/Reject 显示)-->
+				<div v-if="flowState?.canSupervisorApprove || flowState?.canSupervisorReject" class="flow-op mt12">
+					<div class="flow-op-title">检验主管审核</div>
+					<el-button type="success" :loading="flowSubmitting" @click="onApprove">通过</el-button>
+					<el-button type="warning" :loading="flowSubmitting" @click="onReject">退回</el-button>
+				</div>
+
+				<!-- SQE 处置(仅 canSqeSubmit 显示)-->
+				<div v-if="flowState?.canSqeSubmit" class="flow-op mt12">
+					<div class="flow-op-title">SQE 处置(不合格)</div>
+					<div class="sqe-hint">不合格数量 {{ dashNum(detail.unqualifiedQty) }} · 检验员处理方式(clfs) {{ dash(detail.processMethod) }}</div>
+					<el-form :model="sqeForm" label-width="96px" size="small" class="mt12" @submit.prevent>
+						<el-form-item label="处置方式">
+							<el-select v-model="sqeForm.dispositionType" style="width: 180px">
+								<el-option :value="0" label="让步接收" />
+								<el-option :value="1" label="挑选" />
+								<el-option :value="2" label="报废" />
+							</el-select>
+						</el-form-item>
+						<el-form-item label="处置数量">
+							<el-input-number v-model="sqeForm.dispositionQty" :min="0" :controls="false" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="让步数量">
+							<el-input-number v-model="sqeForm.concessionQty" :min="0" :controls="false" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="挑选数量">
+							<el-input-number v-model="sqeForm.selectionQty" :min="0" :controls="false" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="报废数量">
+							<el-input-number v-model="sqeForm.scrapQty" :min="0" :controls="false" style="width: 180px" />
+						</el-form-item>
+						<el-form-item label="处置意见">
+							<el-input v-model="sqeForm.dispositionOpinion" type="textarea" :rows="2" style="width: 420px" />
+						</el-form-item>
+						<el-form-item label="供应商回复">
+							<el-input v-model="sqeForm.supplierResponse" type="textarea" :rows="2" style="width: 420px" />
+						</el-form-item>
+						<el-form-item>
+							<el-button type="primary" :loading="flowSubmitting" @click="onSqeSubmit">提交 SQE 处置</el-button>
+						</el-form-item>
+					</el-form>
+				</div>
+			</template>
+
+			<el-empty v-else-if="!loading" :description="loadError || '未找到该检验单(不存在或不属于当前租户)'">
+				<el-button type="primary" @click="goList">返回来料检验任务列表</el-button>
+			</el-empty>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS5IqcInspBillDetail">
+// S5 IQC 检验单详情 · 独立整页(Batch 4B)
+//
+// 由 /aidop/s5/iqc/detail/:id 隐藏路由进入,取代原 IqcInspBillDetailDrawer 侧边抽屉。
+// 与 S7 FQC 详情页同构(整页 + 顶部返回/单号/状态 + 区块化 descriptions),
+// 但业务模型完全沿用 S5 自己的:项目实例 / 不定长样本 / NUMERIC·NON_NUMERIC /
+// AUTO·MANUAL / LEGACY 空态 / 后端 canEdit —— 不引入 FQC 的 j1..j50。
+//
+// 【授权】编辑权唯一 authority 是后端 detail.canEdit(Edit Gate),页面不自行按节点推断。
+// 【租户】前端不提交 tenantId,租户边界完全由 Batch 3 后端 JWT 解析决定。
+// 【缓存】路由 IsKeepAlive=false;此外仍 watch route.params.id 主动重置+重载,双保险防串单。
+import { computed, onMounted, reactive, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchIqcInspBillDetail, type IqcInspBillDetail } from '../api/iqcInspBill';
+import {
+	fetchIqcInspBillFlowState,
+	submitIqcInspBillResult,
+	approveIqcInspBill,
+	rejectIqcInspBill,
+	sqeSubmitIqcInspBillDisposition,
+	type IqcFlowState,
+} from '../api/iqcInspBillFlow';
+import { saveIqcInspDetail, type IqcSaveDetailPayload } from '../api/iqcInspection';
+import IqcInspBillItemPanel from './components/IqcInspBillItemPanel.vue';
+
+/** 列表页路由:返回按钮的兜底目标(三个详情入口均在该页) */
+const IQC_LIST_PATH = '/aidop/s5/iqc/task-list';
+
+const route = useRoute();
+const router = useRouter();
+const pageTitle = computed(() => (route.meta?.title as string) || 'IQC检验单详情');
+
+const detail = ref<IqcInspBillDetail | null>(null);
+const flowState = ref<IqcFlowState | null>(null);
+const loading = ref(false);
+const flowLoading = ref(false);
+const flowSubmitting = ref(false);
+const savingItemId = ref<number | string | null>(null);
+const loadError = ref('');
+
+/** 路由原始参数(仅用于错误提示展示,不参与业务) */
+const rawId = computed(() => {
+	const v = route.params.id;
+	return Array.isArray(v) ? v[0] : v;
+});
+
+/**
+ * 业务 identity 唯一来源 = route.params.id。
+ * 后端 long 序列化为 JSON 字符串,这里只做「正整数字符串」校验,不做 Number 转换,
+ * 避免超出 JS 安全整数范围后精度丢失(billId 量级为 15 位以上)。
+ */
+const billId = computed<string>(() => {
+	const v = rawId.value;
+	return typeof v === 'string' ? v.trim() : '';
+});
+const invalidId = computed(() => !/^\d+$/.test(billId.value) || /^0+$/.test(billId.value));
+
+const flowTagType = computed(() => {
+	switch (flowState.value?.flowStatus) {
+		case 'Approved':
+			return 'success';
+		case 'Rejected':
+		case 'Terminated':
+		case 'Cancelled':
+			return 'danger';
+		case 'Running':
+			return 'warning';
+		default:
+			return 'info';
+	}
+});
+
+// ── 录入 / 处置表单(自 Drawer 平移,逻辑不变)──
+
+const resultForm = reactive<{ pd: number; dhsl: number; bhgsl: number; clfs: number | null; comment: string }>({
+	pd: 0,
+	dhsl: 0,
+	bhgsl: 0,
+	clfs: null,
+	comment: '',
+});
+
+const sqeForm = reactive<{
+	dispositionType: number;
+	dispositionQty: number | null;
+	concessionQty: number | null;
+	selectionQty: number | null;
+	scrapQty: number | null;
+	dispositionOpinion: string;
+	supplierResponse: string;
+}>({
+	dispositionType: 0,
+	dispositionQty: null,
+	concessionQty: null,
+	selectionQty: null,
+	scrapQty: null,
+	dispositionOpinion: '',
+	supplierResponse: '',
+});
+
+/**
+ * 表单重置。原 Drawer 靠 watch(modelValue) 在「打开」时重置;
+ * 独立页无可见性概念,改为「加载函数内联重置」(与 S7 fqcInspResultDetail 的 resetForms 同机制)。
+ */
+function resetForms() {
+	resultForm.pd = 0;
+	resultForm.dhsl = 0;
+	resultForm.bhgsl = 0;
+	resultForm.clfs = null;
+	resultForm.comment = '';
+	sqeForm.dispositionType = 0;
+	sqeForm.dispositionQty = null;
+	sqeForm.concessionQty = null;
+	sqeForm.selectionQty = null;
+	sqeForm.scrapQty = null;
+	sqeForm.dispositionOpinion = '';
+	sqeForm.supplierResponse = '';
+}
+
+/** 切换单据时清空一切与上一单相关的状态,杜绝 A 单残留到 B 单 */
+function resetPageState() {
+	detail.value = null;
+	flowState.value = null;
+	loadError.value = '';
+	savingItemId.value = null;
+	flowSubmitting.value = false;
+	resetForms();
+}
+
+// ── 数据加载 ──
+
+async function loadFlowState(id: string) {
+	flowLoading.value = true;
+	try {
+		flowState.value = await fetchIqcInspBillFlowState(id);
+	} catch (e: any) {
+		flowState.value = null;
+		ElMessage.error(e?.message || '加载流程状态失败');
+	} finally {
+		flowLoading.value = false;
+	}
+}
+
+/** 页面统一加载入口:非法参数不发任何请求 */
+async function loadPage() {
+	if (invalidId.value) {
+		resetPageState();
+		return;
+	}
+	const id = billId.value;
+	loading.value = true;
+	loadError.value = '';
+	try {
+		const [data] = await Promise.all([fetchIqcInspBillDetail(id), loadFlowState(id)]);
+		if (!data) {
+			detail.value = null;
+			loadError.value = '未找到该检验单(不存在或不属于当前租户)';
+			return;
+		}
+		detail.value = data;
+		resetForms();
+	} catch (e: any) {
+		detail.value = null;
+		loadError.value = e?.message || '加载检验单详情失败';
+	} finally {
+		loading.value = false;
+	}
+}
+
+/** 仅重取 detail(save-detail 不推进流程,无需刷新 flow state) */
+async function reloadDetailOnly() {
+	if (invalidId.value) return;
+	const data = await fetchIqcInspBillDetail(billId.value);
+	if (data) detail.value = data;
+}
+
+/** 流程动作后:detail + flowState 一起重取,编辑态实时翻转(不依赖任何缓存) */
+async function refreshAfterAction() {
+	if (invalidId.value) return;
+	const [data] = await Promise.all([fetchIqcInspBillDetail(billId.value), loadFlowState(billId.value)]);
+	if (data) detail.value = data;
+	resetForms();
+}
+
+// ── 项目级结果保存 ──
+// 链路已缩短为 ItemPanel → 本页 → API(原 Drawer 中转层已删除)。
+// 失败时后端消息由 request.ts 响应拦截器原样弹出,此处不二次 ElMessage,
+// 避免叠加 axios 的 "Request failed with status code 400" 技术文案。
+async function onSaveItemDetail(payload: IqcSaveDetailPayload, itemId: number | string) {
+	savingItemId.value = itemId;
+	try {
+		const res = await saveIqcInspDetail(payload);
+		if (res?.clearedByTypeChange?.length) ElMessage.warning('录入类型已切换,该项目原有测量事实已清空');
+		if (res?.manualInvalidated?.length) ElMessage.warning('测量结果已变更,原人工判定已复位为自动判定');
+		ElMessage.success('检验项目结果已保存');
+		await reloadDetailOnly();
+	} catch {
+		/* 后端消息已由响应拦截器弹出 */
+	} finally {
+		savingItemId.value = null;
+	}
+}
+
+// ── 流程动作(自 Drawer 平移,校验逻辑逐字不变,emit 改为直接调用 API)──
+
+function onPdChange() {
+	if (resultForm.pd === 0) {
+		resultForm.bhgsl = 0;
+		resultForm.clfs = null;
+	}
+}
+
+async function onSubmitResult() {
+	if (resultForm.pd !== 0 && resultForm.pd !== 1) return ElMessage.error('请选择判定');
+	if (resultForm.dhsl < 0) return ElMessage.error('合格数量不能为负');
+	if (resultForm.bhgsl < 0) return ElMessage.error('不合格数量不能为负');
+	if (resultForm.pd === 0 && resultForm.bhgsl !== 0) return ElMessage.error('判定合格时不合格数量必须为 0');
+	if (resultForm.pd === 1 && resultForm.bhgsl <= 0) return ElMessage.error('判定不合格时不合格数量必须大于 0');
+	if (resultForm.pd === 1 && (resultForm.clfs === null || resultForm.clfs === undefined)) return ElMessage.error('判定不合格时请选择处理方式');
+	const id = detail.value?.id;
+	if (!id) return ElMessage.error('检验单 id 缺失');
+	flowSubmitting.value = true;
+	try {
+		await submitIqcInspBillResult({
+			id,
+			pd: resultForm.pd,
+			dhsl: resultForm.dhsl,
+			bhgsl: resultForm.bhgsl,
+			clfs: resultForm.pd === 1 ? resultForm.clfs : null,
+			comment: resultForm.comment,
+		});
+		ElMessage.success('检验结果已提交(已触发回写 WMS),转检验主管审核');
+		await refreshAfterAction();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '提交检验结果失败');
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onApprove() {
+	const id = detail.value?.id;
+	if (!id) return;
+	flowSubmitting.value = true;
+	try {
+		await approveIqcInspBill(id);
+		ElMessage.success('已通过,流程完成');
+		await refreshAfterAction();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '审核通过失败');
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onReject() {
+	const id = detail.value?.id;
+	if (!id) return;
+	let comment = '';
+	try {
+		const { value } = await ElMessageBox.prompt('请填写退回意见', '退回', {
+			confirmButtonText: '确定退回',
+			cancelButtonText: '取消',
+			inputType: 'textarea',
+			inputValidator: (v: string) => (v && v.trim() ? true : '退回意见不能为空'),
+		});
+		comment = value.trim();
+	} catch {
+		return; /* 用户取消 */
+	}
+	flowSubmitting.value = true;
+	try {
+		await rejectIqcInspBill(id, comment);
+		ElMessage.success('已退回检验员');
+		await refreshAfterAction();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '退回失败');
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onSqeSubmit() {
+	if (sqeForm.dispositionType !== 0 && sqeForm.dispositionType !== 1 && sqeForm.dispositionType !== 2)
+		return ElMessage.error('请选择处置方式');
+	if (!sqeForm.dispositionOpinion || !sqeForm.dispositionOpinion.trim()) return ElMessage.error('请填写处置意见');
+	for (const q of [sqeForm.dispositionQty, sqeForm.concessionQty, sqeForm.selectionQty, sqeForm.scrapQty]) {
+		if (q !== null && q !== undefined && q < 0) return ElMessage.error('处置数量不能为负');
+	}
+	const id = detail.value?.id;
+	if (!id) return ElMessage.error('检验单 id 缺失');
+	flowSubmitting.value = true;
+	try {
+		await sqeSubmitIqcInspBillDisposition({
+			id,
+			dispositionType: sqeForm.dispositionType,
+			dispositionQty: sqeForm.dispositionQty,
+			concessionQty: sqeForm.concessionQty,
+			selectionQty: sqeForm.selectionQty,
+			scrapQty: sqeForm.scrapQty,
+			dispositionOpinion: sqeForm.dispositionOpinion.trim(),
+			supplierResponse: sqeForm.supplierResponse || undefined,
+		});
+		ElMessage.success('SQE 处置已提交,流程完成');
+		await refreshAfterAction();
+	} catch (e: any) {
+		ElMessage.error(e?.message || 'SQE 处置提交失败');
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+// ── 展示格式化(自 Drawer 平移;空值一律 '-',绝不出现 null/undefined 字面量)──
+
+function dash(v: unknown) {
+	if (v === null || v === undefined || v === '') return '-';
+	return String(v);
+}
+function dashNum(v?: number | null) {
+	if (v === null || v === undefined) return '-';
+	return String(v);
+}
+function fmtDateTime(v?: string | null) {
+	if (!v) return '-';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+
+// ── 导航 ──
+
+function goList() {
+	router.push(IQC_LIST_PATH);
+}
+
+function goBack() {
+	if (window.history.length > 1) router.back();
+	else goList();
+}
+
+// ── 生命周期 ──
+// 路由 IsKeepAlive=false 已保证换单必然重建组件;此 watch 是防御性双保险:
+// 即便未来框架路由复用策略变化,也不会把 A 单状态残留到 B 单。
+watch(
+	() => route.params.id,
+	() => {
+		resetPageState();
+		loadPage();
+	}
+);
+
+onMounted(loadPage);
+</script>
+
+<style scoped lang="scss">
+.page-head {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-bottom: 12px;
+	.bill-no {
+		font-weight: bold;
+		font-size: 16px;
+	}
+}
+.detail-body {
+	min-height: 160px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.pre {
+	white-space: pre-wrap;
+	word-break: break-word;
+}
+.flow-op {
+	padding: 12px;
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+	background: var(--el-fill-color-light);
+}
+.flow-op-title {
+	font-weight: bold;
+	margin-bottom: 10px;
+}
+.sqe-hint {
+	color: var(--el-text-color-secondary);
+	font-size: 12px;
+}
+</style>

+ 14 - 138
Web/src/views/aidop/s5/iqc/iqcTaskList.vue

@@ -63,7 +63,7 @@
 							>
 								来料检验
 							</el-button>
-							<el-button v-else type="primary" link @click="openDetailById(row.inspBillId)">查看检验单</el-button>
+							<el-button v-else type="primary" link @click="goDetail(row.inspBillId)">查看检验单</el-button>
 						</template>
 					</el-table-column>
 					<template #empty>
@@ -114,7 +114,7 @@
 					<el-table-column prop="billStatus" label="状态" min-width="90" />
 					<el-table-column label="操作" width="90" fixed="right">
 						<template #default="{ row }">
-							<el-button type="primary" link @click="openDetail(row)">查看</el-button>
+							<el-button type="primary" link @click="goDetail(row.id)">查看</el-button>
 						</template>
 					</el-table-column>
 					<template #empty>
@@ -136,19 +136,6 @@
 			</el-tab-pane>
 		</el-tabs>
 
-		<IqcInspBillDetailDrawer
-			v-model="detailVisible"
-			:detail="currentDetail"
-			:loading="detailLoading"
-			:flow-state="currentFlowState"
-			:flow-loading="flowLoading"
-			:submitting="flowSubmitting"
-			@submit-result="onSubmitResult"
-			@approve="onApprove"
-			@reject="onReject"
-			@sqe-submit="onSqeSubmit"
-		/>
-
 		<el-dialog v-model="assignVisible" title="检验员调配" width="420px">
 			<el-form label-width="100px">
 				<el-form-item label="用户 Id">
@@ -177,20 +164,10 @@
 
 <script setup lang="ts" name="aidopS5IqcTaskList">
 import { computed, onActivated, onMounted, reactive, ref } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
-import { fetchIqcInspBillList, fetchIqcInspBillDetail, type IqcInspBillRow, type IqcInspBillDetail } from '../api/iqcInspBill';
-import {
-	fetchIqcInspBillFlowState,
-	submitIqcInspBillResult,
-	approveIqcInspBill,
-	rejectIqcInspBill,
-	sqeSubmitIqcInspBillDisposition,
-	type IqcFlowState,
-	type IqcSubmitResultPayload,
-	type IqcSqeDispositionPayload,
-} from '../api/iqcInspBillFlow';
+import { fetchIqcInspBillList, type IqcInspBillRow } from '../api/iqcInspBill';
 import {
 	fetchIqcTaskEntryList,
 	claimIqcTaskEntries,
@@ -200,9 +177,9 @@ import {
 	generateIqcInspBill,
 	type IqcTaskEntryRow,
 } from '../api/iqcTaskEntry';
-import IqcInspBillDetailDrawer from './components/IqcInspBillDetailDrawer.vue';
 
 const route = useRoute();
+const router = useRouter();
 const pageTitle = computed(() => (route.meta?.title as string) || '来料检验任务列表');
 
 const activeTab = ref('entry');
@@ -246,14 +223,6 @@ const loading = ref(false);
 const rows = ref<IqcInspBillRow[]>([]);
 const total = ref(0);
 
-const detailVisible = ref(false);
-const detailLoading = ref(false);
-const currentDetail = ref<IqcInspBillDetail | null>(null);
-const currentFlowState = ref<IqcFlowState | null>(null);
-const flowLoading = ref(false);
-const flowSubmitting = ref(false);
-const currentId = ref<number | null>(null);
-
 function onTabChange(name: string | number) {
 	if (name === 'entry') loadEntryList();
 	else loadList();
@@ -358,7 +327,7 @@ async function onGenerate(row: IqcTaskEntryRow) {
 		const res = await generateIqcInspBill(row.id);
 		ElMessage.success(`已生成检验单 ${res.inspBillNo}`);
 		await loadEntryList();
-		if (res.inspBillId) await openDetailById(res.inspBillId);
+		if (res.inspBillId) goDetail(res.inspBillId);
 	} catch (e: any) {
 		ElMessage.error(e?.message || '生成检验单失败');
 	} finally {
@@ -366,107 +335,14 @@ async function onGenerate(row: IqcTaskEntryRow) {
 	}
 }
 
-async function loadFlowState(id: number) {
-	flowLoading.value = true;
-	try {
-		currentFlowState.value = await fetchIqcInspBillFlowState(id);
-	} catch (e: any) {
-		currentFlowState.value = null;
-		ElMessage.error(e?.message || '加载流程状态失败');
-	} finally {
-		flowLoading.value = false;
-	}
-}
-
-async function refreshAfterAction() {
-	if (currentId.value) {
-		const [d] = await Promise.all([fetchIqcInspBillDetail(currentId.value), loadFlowState(currentId.value)]);
-		if (d) currentDetail.value = d;
-	}
-	loadList();
-	loadEntryList();
-}
-
-async function openDetailById(id: number) {
-	currentId.value = id;
-	detailLoading.value = true;
-	detailVisible.value = true;
-	currentDetail.value = null;
-	currentFlowState.value = null;
-	try {
-		const [data] = await Promise.all([fetchIqcInspBillDetail(id), loadFlowState(id)]);
-		if (!data) {
-			ElMessage.warning('未找到检验单详情');
-			return;
-		}
-		currentDetail.value = data;
-	} catch (e: any) {
-		ElMessage.error(e?.message || '加载检验单详情失败');
-	} finally {
-		detailLoading.value = false;
-	}
-}
-
-async function openDetail(row: IqcInspBillRow) {
-	if (!row?.id) {
-		ElMessage.error('检验单 id 缺失,无法查看详情');
-		return;
-	}
-	await openDetailById(row.id);
-}
-
-async function onSubmitResult(payload: IqcSubmitResultPayload) {
-	flowSubmitting.value = true;
-	try {
-		await submitIqcInspBillResult(payload);
-		ElMessage.success('检验结果已提交(已触发回写 WMS),转检验主管审核');
-		await refreshAfterAction();
-	} catch (e: any) {
-		ElMessage.error(e?.message || '提交检验结果失败');
-	} finally {
-		flowSubmitting.value = false;
-	}
-}
-
-async function onApprove() {
-	if (!currentId.value) return;
-	flowSubmitting.value = true;
-	try {
-		await approveIqcInspBill(currentId.value);
-		ElMessage.success('已通过,流程完成');
-		await refreshAfterAction();
-	} catch (e: any) {
-		ElMessage.error(e?.message || '审核通过失败');
-	} finally {
-		flowSubmitting.value = false;
-	}
-}
-
-async function onReject(comment: string) {
-	if (!currentId.value) return;
-	flowSubmitting.value = true;
-	try {
-		await rejectIqcInspBill(currentId.value, comment);
-		ElMessage.success('已退回检验员');
-		await refreshAfterAction();
-	} catch (e: any) {
-		ElMessage.error(e?.message || '退回失败');
-	} finally {
-		flowSubmitting.value = false;
-	}
-}
-
-async function onSqeSubmit(payload: IqcSqeDispositionPayload) {
-	flowSubmitting.value = true;
-	try {
-		await sqeSubmitIqcInspBillDisposition(payload);
-		ElMessage.success('SQE 处置已提交,流程完成');
-		await refreshAfterAction();
-	} catch (e: any) {
-		ElMessage.error(e?.message || 'SQE 处置提交失败');
-	} finally {
-		flowSubmitting.value = false;
-	}
+/**
+ * 打开检验单详情:统一跳独立 Tab(隐藏路由 /aidop/s5/iqc/detail/:id)。
+ * 待检任务 Tab、检验单 Tab、生成检验单后自动进入,三个入口共用本函数——不再有 Drawer。
+ * id 直接以字符串拼路径:后端 long 序列化为 JSON 字符串,且存在超出 JS 安全整数范围的大 id。
+ */
+function goDetail(id?: number | string | null) {
+	if (id === null || id === undefined || id === '') return ElMessage.error('检验单 id 缺失,无法查看详情');
+	router.push(`/aidop/s5/iqc/detail/${id}`);
 }
 
 async function loadList() {

+ 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({}) })

+ 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>

+ 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}`">

+ 19 - 2
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。
@@ -161,7 +163,12 @@ function buildStageColumns(stages: StageSnapshot[]): StageColumn[] {
 			name: stage.name,
 			kpiText: kpi != null ? kpi.toFixed(1) : '--',
 			targetDate: stage.targetDate || '--',
-			actualReachedAt: stage.actualReachedAt ?? '尚未达成',
+			// END_UNKNOWN 没有 end 时间,但也不是"尚未达成" —— 是已达成而历史时刻不可考。
+			actualReachedAt: stage.actualReachedAt
+				?? (stage.calculationStatus === 'END_UNKNOWN' ? '历史时间未知' : '尚未达成'),
+			// 只有真实闭环(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 +237,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 +440,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 +500,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">

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

@@ -42,11 +42,33 @@ 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 = '已完成';
+
+// END_UNKNOWN 专用文案。
+// 语义:系统【知道】这张订单已经形成可执行日计划,只是那次下达发生在动作事件上线之前,
+// 历史时间 Authority 缺失。它与 IN_PROGRESS(没有任何证据表明已下达)是两回事 ——
+// 都标成「进行中」会把历史数据缺口误判成业务尚未完成,所以必须单独成文案。
+const NODE_LABEL_END_UNKNOWN = '已完成(历史时间未知)';
+
 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') {
+			// calculationStatus 是阶段 KPI 的权威判定,优先于"有没有时间"的形态推断。
+			// 尤其 END_UNKNOWN:它没有 end 时间,但绝不是"进行中"。
+			if (stage.calculationStatus === 'END_UNKNOWN') label = NODE_LABEL_END_UNKNOWN;
+			else if (stage.calculationStatus === 'NOT_STARTED') label = NODE_LABEL.pending;
+			else if (stage.calculationStatus === 'INVALID_NO_START'
+				|| stage.calculationStatus === 'INVALID_TIME_SEQUENCE') label = NODE_LABEL.pending;
+			else 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;

+ 18 - 0
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -48,6 +48,16 @@
 						<el-button v-else size="small" type="primary" text @click="goToS6(row.bizId)">查看</el-button>
 						<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
 					</template>
+					<template v-else-if="row.bizType === 'S5_IQC_INSPBILL'">
+						<!-- S5 来料检验单:按 nodeId 跳专用详情页,隐藏通用同意/拒绝;未知节点 fail-closed(同 S7)。
+						     注意节点码是 N3_SQE_DISPOSITION(SQE),与 S7 的 N3_QE_DISPOSITION 不同,勿照抄。
+						     后端 FlowTaskService 已对该 bizType 拒绝通用 approve/reject,此处为一致的 UI 侧引导。 -->
+						<el-button v-if="row.nodeId === 'N1_INSPECT'" size="small" type="primary" text @click="goToIqc(row.bizId)">去录入</el-button>
+						<el-button v-else-if="row.nodeId === 'N2_SUP_REVIEW'" size="small" type="primary" text @click="goToIqc(row.bizId)">去审核</el-button>
+						<el-button v-else-if="row.nodeId === 'N3_SQE_DISPOSITION'" size="small" type="primary" text @click="goToIqc(row.bizId)">去处置</el-button>
+						<el-text v-else type="info" size="small">请进入来料检验业务模块处理</el-text>
+						<el-button size="small" text @click="emit('timeline', row)">详情</el-button>
+					</template>
 					<template v-else>
 						<el-button v-if="row.bizType === 'ORDER_CHANGE_REVIEW'" size="small" type="primary" text @click="emit('viewBiz', row)">查看变更单</el-button>
 						<el-button v-if="row.bizType === 'MATERIAL_SHORTAGE'" size="small" type="primary" text @click="emit('viewBiz', row)">查看欠料信息</el-button>
@@ -95,6 +105,14 @@ function goToS6(bizId: number | string) {
 	router.push(`/aidop/s6/production-record/order-list?billId=${bizId}`);
 }
 
+// S5 来料检验(S5_IQC_INSPBILL) deep-link:bizId = qms_qcp_inspbill.id(StartFlow 时以检验单主键写入 BizId)。
+// N1/N2/N3 统一进同一独立详情页,页面按 detail.canEdit / flowState 决定录入 / 主管审核 / SQE 处置 / 只读,
+// 业务动作全走 S5 专用 API(supervisor-approve / supervisor-reject / sqe-submit-disposition)。
+function goToIqc(bizId: number | string) {
+	if (!bizId) return;
+	router.push(`/aidop/s5/iqc/detail/${bizId}`);
+}
+
 function goToFqc(action: 'inspect' | 'review' | 'disposition', bizId: number | string) {
 	// N1/N2/N3 统一进同一详情页 result-detail;页面按 flowState 决定录入/审核/处置/只读(action 仅用于按钮文案)
 	void action;

+ 40 - 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.535</AssemblyVersion>
+    <FileVersion>1.0.535</FileVersion>
+    <Version>1.0.535</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -793,6 +793,42 @@
     <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\1.0.529.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.532.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.532.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.533.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.533.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.529.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.535.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.535.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;

+ 139 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.529.sql

@@ -0,0 +1,139 @@
+-- ============================================================================
+-- 1.0.529 · S5 IQC Inspection Detail · Batch 1 Persistence Foundation
+--
+-- 用途:为后续 IQC item-level inspection 建立持久化基础(两张 Ai-DOP 自有运行表)。
+--       本批只建结构,不写任何数据、不接任何代码路径。
+--       ⇒ 应用本脚本后系统业务行为与应用前完全一致,只是多了两张空表。
+--
+-- 后续批次:Batch 2 生成时写入检规快照 / Batch 3 录入项目结果 / Batch 4 详情 UI。
+--
+-- ── 红线(本脚本自我约束)────────────────────────────────────────────────────
+--   · 只 CREATE TABLE IF NOT EXISTS 两张 ado_s5_* 新表
+--   · 零 ALTER / 零 DROP / 零 INSERT / 零 UPDATE / 零 DELETE
+--   · 不触碰 qms_jygf / qms_jygfzb / qms_qcp_inspbill / qms_qcp_insappnentry
+--     —— 尤其【不新增 qms_jygfzb.lrlx】:qms_* 属源/贴源体系(热回读 INSERT +
+--        MdpDbPushExecutor 回写白名单),非 Ai-DOP 可扩展 Schema
+--   · 不建 FOREIGN KEY(实测 204 张 ado_* 表 FK 数 = 0,符合仓库惯例)
+--   · 不注册 mdp_entity / 不建 mdp_stg / mdp_std / 不改 HotWatch / PushExecutor
+--
+-- ── 数据归属 ────────────────────────────────────────────────────────────────
+--   两张表 = S5 Operational Truth,仅 S5 IQC 模块运行期代码可直接事务读写。
+--   跨模块消费者(S6/S7/S8/S9/看板/KPI/统计/对外供数)一律禁止直接 SELECT/JOIN,
+--   必须走 ado_s5_iqc_* → mdp_entity → MDP staging/standard → consumer。
+--
+-- ── 租户隔离(四层,硬原则)──────────────────────────────────────────────────
+--   ① tenant_id NOT NULL
+--   ② 所有业务 UK / secondary index 首列 = tenant_id(PRIMARY KEY(id) 为物理主键例外)
+--   ③ valid-tenant CHECK:沿用仓库既有范式(全库 21 条 CHECK 中 18 条同形)
+--      tenant_id IS NOT NULL AND tenant_id NOT IN (0,1,1300000000001)
+--      1300000000001 = AdoS0TenantScope.cs:18 MainTenantId(超管未选目标租户的主租户)
+--   ④ 未来所有读写 SQL 必须显式带 tenant_id 谓词(本批无 C# 代码,仅为其备好边界)
+--
+-- ── 单一真源(measurement truth)────────────────────────────────────────────
+--   result_type='NUMERIC'      → 真源 = ado_s5_iqc_insp_bill_item_sample 行集
+--                                 item.qualified_qty / unqualified_qty 必须为 NULL
+--                                 (由 ck_s5_iqc_item_truth 在 DB 层强制)
+--   result_type='NON_NUMERIC'  → 真源 = item.qualified_qty / unqualified_qty
+--                                 该 item 不得有 sample 行
+--                                 (跨表约束,DB CHECK 无法表达;本批【不引入 trigger】,
+--                                   由 Batch 3 的 save-detail 强制 + 巡检 SQL 兜底)
+--   result_type IS NULL        → 不允许记录任何测量事实
+--
+-- ── 列宽依据(实测 qms_jygfzb,36686 行)────────────────────────────────────
+--   jyxm max 242 / jybz max 363 / jygjjyff max 538 / txfl max 255
+--   cyfa max 63  / zs   max 247 / sx,xx    max 7   / xh ∈ [1,91] 且 9 行为 NULL
+--   ★ method_tool 必须 VARCHAR(1000):源 jygjjyff 已有 1 条真实记录 538 字符,
+--     缩为 VARCHAR(500) 会截断(禁止擅自缩短)
+--   行宽按 utf8mb4 声明上限估算 ≈ 18.7KB,远低于 65535 ⇒ 无需任何字符集降级
+-- ============================================================================
+
+-- ============ ① IQC 检验单项目实例(检规快照 + 项目级结果)============
+CREATE TABLE IF NOT EXISTS ado_s5_iqc_insp_bill_item (
+    id                    BIGINT        NOT NULL AUTO_INCREMENT,
+    tenant_id             BIGINT        NOT NULL                COMMENT '租户ID(隔离域,所有读写必带)',
+    inspection_bill_id    BIGINT        NOT NULL                COMMENT '所属检验单 qms_qcp_inspbill.id(唯一关联键,不冗余单号)',
+    seq                   INT           NULL                    COMMENT '展示序号,快照自 qms_jygfzb.xh(可空可重复,不作键)',
+    source_spec_id        BIGINT        NOT NULL                COMMENT '来源检规 qms_jygf.id —— 仅 lineage 追溯,允许未来悬挂',
+    source_spec_item_id   BIGINT        NOT NULL                COMMENT '来源检规明细 qms_jygfzb.id —— 仅 lineage 追溯,允许未来悬挂',
+
+    -- ── Specification Snapshot:Batch 2 生成时一次写入,之后 IMMUTABLE ──
+    inspection_item       VARCHAR(500)  NULL                    COMMENT '快照 qms_jygfzb.jyxm 检验项目(实测 max 242)',
+    inspection_standard   VARCHAR(1000) NULL                    COMMENT '快照 qms_jygfzb.jybz 检验标准(实测 max 363;公差常内嵌于此)',
+    method_tool           VARCHAR(1000) NULL                    COMMENT '快照 qms_jygfzb.jygjjyff 检验工具/方法(实测 max 538 ⇒ 必须 1000,禁缩为 500)',
+    image_category        VARCHAR(500)  NULL                    COMMENT '快照 qms_jygfzb.txfl 图像分类(实测 max 255)',
+    sampling_scheme       VARCHAR(500)  NULL                    COMMENT '快照 qms_jygfzb.cyfa 抽样方案(实测 max 63)',
+    spec_remark           VARCHAR(500)  NULL                    COMMENT '快照 qms_jygfzb.zs 检规注释(实测 max 247)—— 区别于 result_remark',
+    upper_limit           VARCHAR(50)   NULL                    COMMENT '快照 qms_jygfzb.sx 上限(原样文本,不解析)',
+    lower_limit           VARCHAR(50)   NULL                    COMMENT '快照 qms_jygfzb.xx 下限(原样文本,不解析)',
+
+    -- ── Result Contract:运行期事实,仅 N1 态可写(Batch 3) ──
+    result_type           VARCHAR(20)   NULL                    COMMENT '录入类型 NUMERIC/NON_NUMERIC。★非快照★ 不来自检规;N1 首次录入时选定,N1 态可改但须清空本项目测量事实。NULL=未定(合法初态)',
+    qualified_qty         DECIMAL(18,6) NULL                    COMMENT '【NON_NUMERIC 唯一真源】合格数;NUMERIC 时必须为 NULL',
+    unqualified_qty       DECIMAL(18,6) NULL                    COMMENT '【NON_NUMERIC 唯一真源】不合格数;NUMERIC 时必须为 NULL',
+    item_judgement        VARCHAR(16)   NULL                    COMMENT '项目判定 PASS/FAIL/INVALID;NULL=未录。派生值,后端唯一写入方(已删除 PENDING 枚举)',
+    judgement_source      VARCHAR(8)    NULL                    COMMENT 'AUTO=由真源实时重算 / MANUAL=检验员显式结论;测量事实一旦变更即强制复位为 AUTO',
+    result_remark         VARCHAR(500)  NULL                    COMMENT '项目级结果备注(≠ spec_remark)',
+    last_result_by        BIGINT        NULL                    COMMENT '最后录入人 SysUser.Id',
+    last_result_at        DATETIME      NULL                    COMMENT '最后录入时间',
+
+    -- ── Audit:与同类 raw-DDL 表(ado_s6_process_inspection_*)一致,不加 create_user/is_deleted ──
+    create_time           DATETIME      NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time           DATETIME      NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_s5_iqc_item_scope (tenant_id, inspection_bill_id, source_spec_item_id),
+    KEY        idx_s5_iqc_item_src   (tenant_id, source_spec_id),
+
+    CONSTRAINT ck_s5_iqc_item_tenant CHECK (
+        tenant_id IS NOT NULL AND tenant_id NOT IN (0, 1, 1300000000001)
+    ),
+    CONSTRAINT ck_s5_iqc_item_result_type CHECK (
+        result_type IS NULL OR result_type IN ('NUMERIC', 'NON_NUMERIC')
+    ),
+    -- 单一真源:NUMERIC 分支不得携带计数(其真源在 sample 表)。
+    -- NULL 分支显式声明放行,不依赖 MySQL「CHECK 求值为 NULL 视为满足」的隐式行为。
+    CONSTRAINT ck_s5_iqc_item_truth CHECK (
+        result_type IS NULL
+        OR result_type <> 'NUMERIC'
+        OR (qualified_qty IS NULL AND unqualified_qty IS NULL)
+    ),
+    CONSTRAINT ck_s5_iqc_item_judgement CHECK (
+        item_judgement IS NULL OR item_judgement IN ('PASS', 'FAIL', 'INVALID')
+    ),
+    CONSTRAINT ck_s5_iqc_item_jsrc CHECK (
+        judgement_source IS NULL OR judgement_source IN ('AUTO', 'MANUAL')
+    )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S5 IQC检验单项目实例(检规快照+项目级结果)。S5 Operational Truth;跨模块消费须经 MDP,禁止直接 JOIN';
+
+-- ============ ② IQC 检验单项目实测值(逐样本,仅 NUMERIC 使用)============
+CREATE TABLE IF NOT EXISTS ado_s5_iqc_insp_bill_item_sample (
+    id                    BIGINT        NOT NULL AUTO_INCREMENT,
+    tenant_id             BIGINT        NOT NULL                COMMENT '租户ID(隔离域,所有读写必带)',
+    inspection_bill_id    BIGINT        NOT NULL                COMMENT '反规范化:支持按单一次取全部样本免 JOIN',
+    item_id               BIGINT        NOT NULL                COMMENT '所属项目 ado_s5_iqc_insp_bill_item.id',
+    sample_index          INT           NOT NULL                COMMENT '样本序号 1..N(无硬上限,不用 j1..j50 定长列)',
+    sample_value_raw      VARCHAR(200)  NOT NULL                COMMENT '【NUMERIC 唯一真源】检验员录入原文,原样保留(供 INVALID 复盘)。空值语义=该样本不存在⇒删行,不存空串',
+    sample_numeric        DECIMAL(18,6) NULL                    COMMENT '后端解析值;不可解析=NULL(不丢原文)',
+    judgement             VARCHAR(16)   NULL                    COMMENT 'PASS/FAIL/INVALID;后端唯一写入方,前端传值一律忽略',
+    remark                VARCHAR(500)  NULL                    COMMENT '样本级备注',
+    inspector_id          BIGINT        NULL                    COMMENT '样本级录入人 SysUser.Id',
+    inspection_time       DATETIME      NULL                    COMMENT '样本级录入时刻',
+    create_time           DATETIME      NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time           DATETIME      NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_s5_iqc_sample_scope (tenant_id, item_id, sample_index),
+    KEY        idx_s5_iqc_sample_bill (tenant_id, inspection_bill_id),
+
+    CONSTRAINT ck_s5_iqc_sample_tenant CHECK (
+        tenant_id IS NOT NULL AND tenant_id NOT IN (0, 1, 1300000000001)
+    ),
+    CONSTRAINT ck_s5_iqc_sample_index CHECK (
+        sample_index >= 1
+    ),
+    CONSTRAINT ck_s5_iqc_sample_judgement CHECK (
+        judgement IS NULL OR judgement IN ('PASS', 'FAIL', 'INVALID')
+    )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S5 IQC检验单项目实测值(逐样本,仅NUMERIC)。S5 Operational Truth;跨模块消费须经 MDP,禁止直接 JOIN';

+ 129 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.529.verify.sql

@@ -0,0 +1,129 @@
+-- ============================================================================
+-- 1.0.529 校验 · S5 IQC Inspection Detail · Batch 1 Persistence Foundation
+-- 约定:每条必须返回真值,否则 AutoVersionUpdate 判定该版本失败。
+--       全部为纯 SELECT(runner 用 SqlQuerySingle 取首行首列,不支持 SET 变量)。
+--
+-- 覆盖 Batch 1 验收项 A/B/C/D/E/F:表存在 · 列逐项 · tenant-first 索引 · CHECK ·
+--                                  qms_* 未改 · 无业务数据写入
+-- ============================================================================
+
+-- ---- A1 · 两张新表都存在 ----
+SELECT COUNT(*) = 2 FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s5_iqc_insp_bill_item', 'ado_s5_iqc_insp_bill_item_sample');
+
+-- ---- A2 · engine / collation 与仓库惯例一致 ----
+SELECT COUNT(*) = 2 FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s5_iqc_insp_bill_item', 'ado_s5_iqc_insp_bill_item_sample')
+  AND ENGINE = 'InnoDB' AND TABLE_COLLATION = 'utf8mb4_0900_ai_ci';
+
+-- ---- B1 · item 表列数 = 24 ----
+SELECT COUNT(*) = 24 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item';
+
+-- ---- B2 · sample 表列数 = 13 ----
+SELECT COUNT(*) = 13 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item_sample';
+
+-- ---- B3 · method_tool 必须是 varchar(1000)(源 jygjjyff 实测 538 字符,500 会截断) ----
+SELECT COUNT(*) = 1 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item'
+  AND COLUMN_NAME = 'method_tool'
+  AND DATA_TYPE = 'varchar' AND CHARACTER_MAXIMUM_LENGTH = 1000;
+
+-- ---- B4 · 其余快照列宽逐项 ----
+SELECT COUNT(*) = 7 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item'
+  AND ((COLUMN_NAME = 'inspection_item'     AND CHARACTER_MAXIMUM_LENGTH = 500)
+    OR (COLUMN_NAME = 'inspection_standard' AND CHARACTER_MAXIMUM_LENGTH = 1000)
+    OR (COLUMN_NAME = 'image_category'      AND CHARACTER_MAXIMUM_LENGTH = 500)
+    OR (COLUMN_NAME = 'sampling_scheme'     AND CHARACTER_MAXIMUM_LENGTH = 500)
+    OR (COLUMN_NAME = 'spec_remark'         AND CHARACTER_MAXIMUM_LENGTH = 500)
+    OR (COLUMN_NAME = 'upper_limit'         AND CHARACTER_MAXIMUM_LENGTH = 50)
+    OR (COLUMN_NAME = 'lower_limit'         AND CHARACTER_MAXIMUM_LENGTH = 50));
+
+-- ---- B5 · item 表 NOT NULL 业务列恰好是这 4 个(id 由 PK 保证,不计入) ----
+SELECT COUNT(*) = 4 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item'
+  AND IS_NULLABLE = 'NO'
+  AND COLUMN_NAME IN ('tenant_id', 'inspection_bill_id', 'source_spec_id', 'source_spec_item_id');
+
+-- ---- B6 · seq 必须允许 NULL(源 qms_jygfzb.xh 实测 9 行为 NULL) ----
+SELECT COUNT(*) = 1 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item'
+  AND COLUMN_NAME = 'seq' AND IS_NULLABLE = 'YES';
+
+-- ---- B7 · sample 表 NOT NULL 列 ----
+SELECT COUNT(*) = 5 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item_sample'
+  AND IS_NULLABLE = 'NO'
+  AND COLUMN_NAME IN ('tenant_id', 'inspection_bill_id', 'item_id', 'sample_index', 'sample_value_raw');
+
+-- ---- B8 · 两个 DECIMAL 真源列精度 = (18,6),与 ado_s5_iqc_sqe_disposition 惯例一致 ----
+SELECT COUNT(*) = 3 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND ((TABLE_NAME = 'ado_s5_iqc_insp_bill_item'        AND COLUMN_NAME IN ('qualified_qty', 'unqualified_qty'))
+    OR (TABLE_NAME = 'ado_s5_iqc_insp_bill_item_sample' AND COLUMN_NAME = 'sample_numeric'))
+  AND NUMERIC_PRECISION = 18 AND NUMERIC_SCALE = 6;
+
+-- ---- C1 · tenant-first:所有非 PRIMARY 索引的首列必须是 tenant_id ----
+SELECT COUNT(*) = 0 FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s5_iqc_insp_bill_item', 'ado_s5_iqc_insp_bill_item_sample')
+  AND INDEX_NAME <> 'PRIMARY'
+  AND SEQ_IN_INDEX = 1
+  AND COLUMN_NAME <> 'tenant_id';
+
+-- ---- C2 · item UK 恰为 (tenant_id, inspection_bill_id, source_spec_item_id) ----
+SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) = 'tenant_id,inspection_bill_id,source_spec_item_id'
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item'
+  AND INDEX_NAME = 'uk_s5_iqc_item_scope' AND NON_UNIQUE = 0;
+
+-- ---- C3 · sample UK 恰为 (tenant_id, item_id, sample_index) ----
+SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) = 'tenant_id,item_id,sample_index'
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s5_iqc_insp_bill_item_sample'
+  AND INDEX_NAME = 'uk_s5_iqc_sample_scope' AND NON_UNIQUE = 0;
+
+-- ---- C4 · 两张表不得存在 FOREIGN KEY(实测 204 张 ado_* 表 FK 数 = 0) ----
+SELECT COUNT(*) = 0 FROM information_schema.TABLE_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s5_iqc_insp_bill_item', 'ado_s5_iqc_insp_bill_item_sample')
+  AND CONSTRAINT_TYPE = 'FOREIGN KEY';
+
+-- ---- D1 · 8 条 CHECK 约束齐备 ----
+SELECT COUNT(*) = 8 FROM information_schema.TABLE_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ado_s5_iqc_insp_bill_item', 'ado_s5_iqc_insp_bill_item_sample')
+  AND CONSTRAINT_TYPE = 'CHECK'
+  AND CONSTRAINT_NAME IN ('ck_s5_iqc_item_tenant', 'ck_s5_iqc_item_result_type',
+                          'ck_s5_iqc_item_truth', 'ck_s5_iqc_item_judgement',
+                          'ck_s5_iqc_item_jsrc',
+                          'ck_s5_iqc_sample_tenant', 'ck_s5_iqc_sample_index',
+                          'ck_s5_iqc_sample_judgement');
+
+-- ---- D2 · 两条 valid-tenant CHECK 确实排除 0 / 1 / 主租户哨兵 ----
+SELECT COUNT(*) = 2 FROM information_schema.CHECK_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+  AND CONSTRAINT_NAME IN ('ck_s5_iqc_item_tenant', 'ck_s5_iqc_sample_tenant')
+  AND CHECK_CLAUSE LIKE '%1300000000001%'
+  AND CHECK_CLAUSE LIKE '%is not null%';
+
+-- ---- D3 · 单一真源 CHECK 显式覆盖 NUMERIC 分支且不依赖三值逻辑隐式放行 ----
+SELECT COUNT(*) = 1 FROM information_schema.CHECK_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+  AND CONSTRAINT_NAME = 'ck_s5_iqc_item_truth'
+  AND CHECK_CLAUSE LIKE '%result_type%is null%'
+  AND CHECK_CLAUSE LIKE '%qualified_qty%';
+
+-- ---- E1 · qms_jygfzb 不得被新增 lrlx 列(本批对 qms_* 零 ALTER 的结构证明) ----
+SELECT COUNT(*) = 0 FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'qms_jygfzb' AND COLUMN_NAME = 'lrlx';
+
+-- ---- F1 · 本批完成时 item 表必须为空(零业务数据写入) ----
+SELECT COUNT(*) = 0 FROM ado_s5_iqc_insp_bill_item;
+
+-- ---- F2 · 本批完成时 sample 表必须为空 ----
+SELECT COUNT(*) = 0 FROM ado_s5_iqc_insp_bill_item_sample;

+ 81 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.sql

@@ -0,0 +1,81 @@
+-- =====================================================================================
+-- 1.0.532 · S8 Stage-2「产品设计」End Authority 与身份桥的中台补齐
+--
+-- ── 背景 ────────────────────────────────────────────────────────────────────────────
+-- Stage-2 的业务终点是「订单首次具备可执行日计划」,当前系统由
+--   POST /api/Production/daily-plan/release → UPDATE PeriodSequenceDet SET Status='Y'
+-- 表达。但该动作此前存在三个缺口:
+--   G1 不写 action log      → 没有 LEVEL A 事件时间
+--   G2 只能退而用 UpdateTime → 会被后续任意一次 UPDATE 覆盖,不是稳定里程碑
+--   G3 Status 未进标准层     → S8 在中台里根本看不到「是否已下达」
+--   G5 WorkOrdMaster.BusinessID 未进标准层 → 计划→订单的 ID 桥在中台中断
+--
+-- 本脚本负责 G3 / G5 两个结构缺口;G1 / G2 由 ExecutableDailyPlanService 接入
+-- aidop_action_run_log(action_code = S2_DAILY_PLAN_RELEASE)解决。
+--
+-- ── BusinessID 语义实证(改名依据,2026-09-11 全库实测)────────────────────────────
+--   WorkOrdMaster 共 267 行:BusinessID IS NULL = 0,= 0 为 239 行,> 0 为 28 行;
+--   28 行 >0 值 100% 命中【同租户】crm_seorderentry.Id,孤儿 0 行。
+--   写入方 OrderWorkOrderGenerationService.cs:241 明确 `@BusinessId = entry.Id`,
+--   读取方 FindOpenWorkOrderByEntryAsync 亦以 `BusinessID = @EntryId` 反查。
+--   0 表示「该工单无关联订单明细行」(WorkOrderKittingCheckService.cs:53 注释),
+--   不是另一种业务语义,故标准层用明确列名 sales_order_entry_id,并把 0 归一为 NULL。
+--
+-- ── 本脚本不做 ──────────────────────────────────────────────────────────────────────
+-- 不建 dwd_product_design_kpi;不把 sales_order_id 冗余进各 STD 表;
+-- 不回填历史下达事件(历史 Status='Y' 只能证明状态,无强时间 Authority)。
+-- =====================================================================================
+
+-- ---------------------------------------------------------------------------
+-- ① mdp_std_operation_schedule 补 status
+--    语义 = PeriodSequenceDet.Status 原样投影:'Y' = 日计划已下达(可执行),
+--    '' / NULL = 未下达。仅 PeriodSequenceDet 来源的行有值;
+--    同表另一来源 ScheduleResultOpMaster 无此概念,保持 NULL。
+--    ⚠️ 它只是「状态佐证」,Stage-2 End 的时间 Authority 一律取
+--       S2_DAILY_PLAN_RELEASE SUCCESS 的 end_time,禁止用 update_time 充当下达时间。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+       AND `COLUMN_NAME` = 'status'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_operation_schedule` ADD COLUMN `status` VARCHAR(20) NULL COMMENT ''日计划下达状态:Y=已下达(可执行),空/NULL=未下达;源 PeriodSequenceDet.Status'' AFTER `employee`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+       AND `INDEX_NAME` = 'idx_std_op_sched_status'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_operation_schedule` ADD INDEX `idx_std_op_sched_status` (`tenant_id`, `status`, `work_order`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ② mdp_std_work_order_schedule 补 sales_order_entry_id
+--    = WorkOrdMaster.BusinessID(>0 时恒等于 crm_seorderentry.Id,见上方实证)。
+--    这是「日计划 → 工单 → 订单行 → 订单」在中台内唯一的 ID 级桥;
+--    既有 sales_order_no 是 SalesJob 文本,**不得**作为归因 Authority。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+       AND `COLUMN_NAME` = 'sales_order_entry_id'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_work_order_schedule` ADD COLUMN `sales_order_entry_id` BIGINT NULL COMMENT ''销售订单明细行Id = crm_seorderentry.Id(源 WorkOrdMaster.BusinessID;源值0表示无关联订单行,已归一为NULL)'' AFTER `sales_order_no`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+       AND `INDEX_NAME` = 'idx_std_wo_sched_entry'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_work_order_schedule` ADD INDEX `idx_std_wo_sched_entry` (`tenant_id`, `sales_order_entry_id`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ③ 历史数据说明(不在此脚本手工回填)
+--    两个新列由 S2MdpSyncTransformService 的标准层 upsert 在下一次跑批时统一投影,
+--    走既有 mdp_stg_schedule → mdp_std_* 链路,不手工 UPDATE 标准层拼历史结果。
+--    事件层不同:历史上不存在 S2_DAILY_PLAN_RELEASE,本批**不伪造历史 action log**,
+--    历史已下达计划的 Stage-2 End 一律记 HISTORICAL_END_TIME_UNKNOWN。
+-- ---------------------------------------------------------------------------

+ 97 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.verify.sql

@@ -0,0 +1,97 @@
+-- 1.0.532.verify.sql — 每条 SELECT 都必须返回 1
+--
+-- ⚠️ AutoVersionUpdate 按分号切分本文件并逐片断言,故不得放 informational 查询,
+--    且文件必须以最后一条断言的分号结尾(尾部纯注释块会被切成多余语句而判失败)。
+
+-- ── ① 列存在 ────────────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND ((`TABLE_NAME` = 'mdp_std_operation_schedule'  AND `COLUMN_NAME` = 'status')
+             OR (`TABLE_NAME` = 'mdp_std_work_order_schedule' AND `COLUMN_NAME` = 'sales_order_entry_id'))) = 2;
+
+-- ── ② 类型正确(status=varchar / sales_order_entry_id=bigint)──────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+           AND `COLUMN_NAME` = 'status' AND `DATA_TYPE` = 'varchar') = 1;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+           AND `COLUMN_NAME` = 'sales_order_entry_id' AND `DATA_TYPE` = 'bigint') = 1;
+
+-- ── ③ NULL 策略正确:两列都必须可空 ────────────────────────────────────────────────
+--     status:ScheduleResultOpMaster 来源的行天然没有该概念
+--     sales_order_entry_id:BusinessID=0(无关联订单行)必须落 NULL,不能落 0
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND ((`TABLE_NAME` = 'mdp_std_operation_schedule'  AND `COLUMN_NAME` = 'status')
+             OR (`TABLE_NAME` = 'mdp_std_work_order_schedule' AND `COLUMN_NAME` = 'sales_order_entry_id'))
+           AND `IS_NULLABLE` = 'YES') = 2;
+
+-- ── ④ 桥索引已建 ───────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(DISTINCT `INDEX_NAME`) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `INDEX_NAME` IN ('idx_std_op_sched_status','idx_std_wo_sched_entry')) = 2;
+
+-- ── ⑤ transform 投影正确性 ─────────────────────────────────────────────────────────
+--  断言口径说明(重要):
+--    这两列是【新增列】,存量标准层行要等各自租户的 S2 跑批扫到才会被填充。
+--    "还没扫到 → 值为 NULL" 与 "扫到了但投影错了" 是两件事,只有后者是缺陷。
+--    因此这里断言的是【投影正确性】而不是【全量覆盖率】,拆成两条互补的断言:
+--      (a) 凡是已填充的值,必须与贴源层逐行一致        → 抓"投影错"
+--      (b) 至少要有一行被填充                          → 抓"压根没接上投影"
+--    只放宽其一都会漏掉真实缺陷,两条必须同时成立。
+--    覆盖率本身不是本迁移的验收项(受跑批调度影响),需要观察时请另跑只读查询。
+
+-- (a-1) status:已填充的行必须等于 stg.raw_data.$.Status
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` o
+          JOIN `mdp_stg_schedule` g
+            ON g.`tenant_id` = o.`tenant_id`
+           AND g.`source_biz_key` = o.`source_biz_key`
+           AND g.`source_table` = 'PeriodSequenceDet'
+         WHERE o.`status` IS NOT NULL
+           AND o.`status` <> IFNULL(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.Status')), '')) = 0;
+
+-- (b-1) status 投影确实被执行过
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` WHERE `status` IS NOT NULL) > 0;
+
+-- (a-2) sales_order_entry_id:已填充的值必须等于 stg 的 BusinessID
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` w
+          JOIN `mdp_stg_schedule` g
+            ON g.`tenant_id` = w.`tenant_id`
+           AND g.`source_biz_key` = w.`source_biz_key`
+           AND g.`source_table` = 'WorkOrdMaster'
+         WHERE w.`sales_order_entry_id` IS NOT NULL
+           AND w.`sales_order_entry_id` <>
+               CAST(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.BusinessID')) AS SIGNED)) = 0;
+
+-- (b-2) sales_order_entry_id 投影确实被执行过
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` WHERE `sales_order_entry_id` IS NOT NULL) > 0;
+
+-- ── ⑥ 不得出现 sales_order_entry_id = 0(0 必须归一为 NULL)─────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` WHERE `sales_order_entry_id` = 0) = 0;
+
+-- ── ⑦ Tenant 无串扰:中台桥解析出的订单行,其租户必须与工单行一致 ──────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` w
+          JOIN `mdp_std_so` s ON s.`order_entry_id` = w.`sales_order_entry_id`
+         WHERE w.`sales_order_entry_id` IS NOT NULL
+           AND s.`tenant_id` <> w.`tenant_id`) = 0;
+
+-- ── ⑧ MDP-ONLY 桥可跑通:至少存在一条 计划→工单→订单行→订单 的中台闭环 ────────────
+--     全程只读 mdp_std_*,不触碰任何源业务表
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` op
+          JOIN `mdp_std_work_order_schedule` wo
+            ON wo.`tenant_id` = op.`tenant_id` AND wo.`work_order` = op.`work_order`
+          JOIN `mdp_std_so` so
+            ON so.`tenant_id` = wo.`tenant_id` AND so.`order_entry_id` = wo.`sales_order_entry_id`
+         WHERE wo.`sales_order_entry_id` IS NOT NULL
+           AND so.`order_id` IS NOT NULL) > 0;
+
+-- ── ⑨ 本批禁止建 Stage-2 KPI 表 ────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi') = 0;
+
+-- ── ⑩ 不得伪造历史事件:S2_DAILY_PLAN_RELEASE 只能有本批上线后真实产生的记录 ────────
+--     (断言其不存在 end_time 早于本迁移引入时间的"历史补录"行——此处只校验无 RUNNING 残留孤儿)
+SELECT (SELECT COUNT(*) FROM `aidop_action_run_log`
+         WHERE `action_code` = 'S2_DAILY_PLAN_RELEASE'
+           AND (`tenant_id` IS NULL OR `tenant_id` = 0)) = 0;

+ 54 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.533.sql

@@ -0,0 +1,54 @@
+-- =====================================================================================
+-- 1.0.533 · S8 Stage-2「产品设计」KPI 事实层
+--
+-- ── 链路 ────────────────────────────────────────────────────────────────────────────
+--   dwd_order_review_kpi.review_end_time                (Stage-1 已有 DWD Authority)
+--     + mdp_std_action_event (S2_DAILY_PLAN_RELEASE)    (Stage-2 End Authority,1.0.532 建立)
+--     + mdp_std_operation_schedule.status='Y'           (历史"已下达"状态佐证,只作旁证不取时间)
+--     → dwd_product_design_kpi → S8 订单执行档案 PRODUCT_DESIGN
+--
+-- ── 历史语义(本表存在的核心理由)────────────────────────────────────────────────────
+-- 1.0.532 之前系统不写「日计划下达」动作事件,因此历史上已下达的订单
+-- **只能证明"已经下达",无法证明"何时下达"**。本表用独立状态 END_UNKNOWN 表达这件事,
+-- 与 IN_PROGRESS 严格区分:
+--   END_UNKNOWN  = 已形成可执行日计划,但历史时间 Authority 缺失
+--   IN_PROGRESS  = 没有任何证据表明已形成可执行日计划
+-- 若把两者都显示成「进行中」,会把历史数据缺口误判成业务未完成。
+--
+-- 🔴 绝对禁止用 PeriodSequenceDet.UpdateTime / create_time / EffTime / IsConfirm
+--    反推历史 End —— UpdateTime 会被后续任意一次 UPDATE 覆盖,拿它当下达时间正是
+--    1.0.532 要消除的问题。END_UNKNOWN 行的 end_time 与 actual_hours 一律 NULL。
+--
+-- ── 本表不含 ────────────────────────────────────────────────────────────────────────
+-- target_hours / kpi_status / 红黄绿阈值 / achievement_rate —— 属另一批次。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `dwd_product_design_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(归因权威,来自 mdp_std_so.order_id)',
+  `sales_order_no`            VARCHAR(128)      NULL                COMMENT '销售订单号(仅展示/搜索,不作 JOIN Authority)',
+
+  `review_end_time`           DATETIME          NULL                COMMENT 'Stage-1 评审结束时刻(原样取自 dwd_order_review_kpi,不重算)',
+  `release_action_run_id`     BIGINT            NULL                COMMENT '被采纳的 S2_DAILY_PLAN_RELEASE 事件ID(FIRST SUCCESS)',
+
+  `product_design_start_time` DATETIME          NULL                COMMENT 'Stage-2 开始 = review_end_time',
+  `product_design_end_time`   DATETIME          NULL                COMMENT 'Stage-2 结束 = FIRST SUCCESS 释放事件的 action_end_time;END_UNKNOWN/IN_PROGRESS 时为 NULL',
+  `actual_hours`              DECIMAL(12,2)     NULL                COMMENT '实际耗时(自然 elapsed 小时,2 位小数,不扣夜间/周末/节假日);仅 OK 有值',
+
+  `calculation_status`        VARCHAR(32)   NOT NULL                COMMENT 'OK / NOT_STARTED / IN_PROGRESS / END_UNKNOWN / INVALID_NO_START / INVALID_TIME_SEQUENCE',
+
+  `release_success_count`     INT           NOT NULL DEFAULT 0      COMMENT '该订单的释放事件 SUCCESS 次数(可观测性,不参与计算)',
+  `release_failed_count`      INT           NOT NULL DEFAULT 0      COMMENT '该订单的释放事件 FAILED 次数(可观测性,不参与计算)',
+  `has_released_schedule`     TINYINT       NOT NULL DEFAULT 0      COMMENT '中台是否存在该订单已下达(status=Y)的日计划行;仅用于区分 END_UNKNOWN / IN_PROGRESS,不取时间',
+
+  `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_product_design_kpi` (`tenant_id`, `sales_order_id`),
+  KEY `idx_dwd_pd_kpi_status` (`tenant_id`, `calculation_status`),
+  KEY `idx_dwd_pd_kpi_no` (`tenant_id`, `sales_order_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DWD-产品设计KPI(Stage-2;本批仅 ActualHours,无 TargetHours)';

+ 123 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.533.verify.sql

@@ -0,0 +1,123 @@
+-- 1.0.533.verify.sql — 每条 SELECT 都必须返回 1
+--
+-- ⚠️ 本项目的 AutoVersionUpdate 按分号切分本文件,并把【每一个切片】都当断言、要求返回 1。
+--    因此:① 不得放"仅供排查"的 informational 查询(它返回结果集不是 1);
+--          ② 文件必须以最后一条断言的分号结尾,**末尾不能留纯注释块** ——
+--             尾部注释会被切成多出来的一条语句,同样导致迁移判失败、后端拒绝启动。
+--    需要看状态分布请另跑独立只读查询,不要写进 verify。
+--
+-- 结构断言(①~⑤)迁移后即可全过;数据断言(⑥~⑰)在表为空时也成立(COUNT=0),
+-- transform 跑过之后才真正有判别力。
+
+-- ── ① 表存在 ────────────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi') = 1;
+
+-- ── ② Unique Key 正确:必须是 (tenant_id, sales_order_id),且 tenant_id 为首列 ──────
+SELECT (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
+          FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi'
+           AND `INDEX_NAME` = 'uk_dwd_product_design_kpi') = 'tenant_id,sales_order_id';
+
+-- ── ③ tenant_id / sales_order_id 必须 NOT NULL ─────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi'
+           AND `COLUMN_NAME` IN ('tenant_id','sales_order_id')
+           AND `IS_NULLABLE` = 'NO') = 2;
+
+-- ── ④ actual_hours 类型正确(decimal,不能是整型/字符串)──────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi'
+           AND `COLUMN_NAME` = 'actual_hours' AND `DATA_TYPE` = 'decimal') = 1;
+
+-- ── ⑤ 本批不得引入 TargetHours / 红黄绿相关列 ──────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi'
+           AND `COLUMN_NAME` IN ('target_hours','kpi_status','yellow_threshold',
+                                 'red_threshold','achievement_rate')) = 0;
+
+-- ── ⑥ 无非法租户 ───────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `tenant_id` IS NULL OR `tenant_id` <= 0 OR `sales_order_id` <= 0) = 0;
+
+-- ── ⑦ calculation_status 取值只允许 6 种 ───────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` NOT IN
+           ('OK','NOT_STARTED','IN_PROGRESS','END_UNKNOWN','INVALID_NO_START','INVALID_TIME_SEQUENCE')) = 0;
+
+-- ── ⑧ OK 行:start/end 都有值、end > start、actual_hours 有值且与时间差一致 ────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` = 'OK'
+           AND (`product_design_start_time` IS NULL
+             OR `product_design_end_time` IS NULL
+             OR `product_design_end_time` <= `product_design_start_time`
+             OR `actual_hours` IS NULL
+             OR ABS(`actual_hours` - ROUND(TIMESTAMPDIFF(SECOND,
+                    `product_design_start_time`, `product_design_end_time`) / 3600.0, 2)) > 0.005)) = 0;
+
+-- ── ⑨ END_UNKNOWN 行:end 必须 NULL(严禁用 update_time 伪造历史 End)─────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` = 'END_UNKNOWN' AND `product_design_end_time` IS NOT NULL) = 0;
+
+-- ── ⑩ END_UNKNOWN 行:actual_hours 必须 NULL,且必须确有已下达日计划佐证 ───────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` = 'END_UNKNOWN'
+           AND (`actual_hours` IS NOT NULL
+             OR `product_design_start_time` IS NULL
+             OR `has_released_schedule` <> 1)) = 0;
+
+-- ── ⑪ IN_PROGRESS 行:start 有值、end 为 NULL、且【无】已下达日计划佐证 ────────────
+--     这一条是 END_UNKNOWN / IN_PROGRESS 不被混淆的结构性保证
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` = 'IN_PROGRESS'
+           AND (`product_design_start_time` IS NULL
+             OR `product_design_end_time` IS NOT NULL
+             OR `actual_hours` IS NOT NULL
+             OR `has_released_schedule` <> 0)) = 0;
+
+-- ── ⑫ NOT_STARTED 行:start / end / hours 全 NULL ──────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` = 'NOT_STARTED'
+           AND (`product_design_start_time` IS NOT NULL
+             OR `product_design_end_time` IS NOT NULL
+             OR `actual_hours` IS NOT NULL)) = 0;
+
+-- ── ⑬ INVALID_* 行:一律不得给出 actual_hours ──────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi`
+         WHERE `calculation_status` IN ('INVALID_NO_START','INVALID_TIME_SEQUENCE')
+           AND `actual_hours` IS NOT NULL) = 0;
+
+-- ── ⑭ Start 必须原样取自 Stage-1 DWD,不得自行重算 ─────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi` p
+          LEFT JOIN `dwd_order_review_kpi` k
+            ON k.`tenant_id` = p.`tenant_id` AND k.`sales_order_id` = p.`sales_order_id`
+         WHERE IFNULL(p.`product_design_start_time`, '1900-01-01')
+            <> IFNULL(k.`review_end_time`, '1900-01-01')) = 0;
+
+-- ── ⑮ End 必须等于该订单 FIRST SUCCESS 释放事件的 action_end_time(不是 LAST/MAX)──
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi` p
+         WHERE p.`product_design_end_time` IS NOT NULL
+           AND p.`product_design_end_time` <> (
+                 SELECT MIN(e.`action_end_time`) FROM `mdp_std_action_event` e
+                  WHERE e.`tenant_id` = p.`tenant_id`
+                    AND e.`biz_id` = p.`sales_order_id`
+                    AND e.`action_code` = 'S2_DAILY_PLAN_RELEASE'
+                    AND e.`biz_type` = 'crm_seorder'
+                    AND e.`action_status` = 'SUCCESS')) = 0;
+
+-- ── ⑯ Event Identity 一致:被采纳事件必须真属于同租户同订单 ────────────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi` p
+         WHERE p.`release_action_run_id` IS NOT NULL
+           AND NOT EXISTS (
+                 SELECT 1 FROM `mdp_std_action_event` e
+                  WHERE e.`action_run_id` = p.`release_action_run_id`
+                    AND e.`tenant_id` = p.`tenant_id`
+                    AND e.`biz_id` = p.`sales_order_id`
+                    AND e.`action_code` = 'S2_DAILY_PLAN_RELEASE'
+                    AND e.`action_status` = 'SUCCESS')) = 0;
+
+-- ── ⑰ 订单身份必须来自 mdp_std_so(MDP-only,不依赖任何源业务表)──────────────────
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi` p
+         WHERE NOT EXISTS (
+                 SELECT 1 FROM `mdp_std_so` s
+                  WHERE s.`tenant_id` = p.`tenant_id` AND s.`order_id` = p.`sales_order_id`)) = 0;

+ 43 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.535.sql

@@ -0,0 +1,43 @@
+-- 1.0.535:Batch 4B · S5 IQC 检验单详情统一页 detail/:id 隐藏菜单 + 租户可见 + 角色授权
+-- 背景:原「列表页内嵌 el-drawer」(IqcInspBillDetailDrawer)收敛为单一路由页
+--       /aidop/s5/iqc/detail/:id(组件 /aidop/s5/iqc/iqcInspBillDetail)。
+--       来料检验任务列表的「待检任务」「检验单」两个 Tab、以及生成检验单后的自动进入,
+--       三个入口统一跳此页;页面按后端 detail.canEdit / flowState 决定录入 / 审核 / 处置 / 只读。
+--       与 S7 FQC 的 1.0.382(result-detail 隐藏菜单)同构。
+-- 幂等:菜单按 Id NOT EXISTS;租户可见性按 (TenantId,MenuId) NOT EXISTS;角色授权按 (RoleId,MenuId) NOT EXISTS。
+--       多行 Id 用 MAX(Id) + ROW_NUMBER() 一次性算定 —— 避免 @变量在 MySQL 8 求值顺序未定义导致重复 PK
+--       (窗口函数在 WHERE 之后求值,故行号只覆盖真正会插入的行,不会产生空洞或冲突)。
+-- 授权口径:Detail menu authority = IQC business entry authority。
+--       直接**继承**「来料检验任务级列表」(Id=1329015010001,即三个详情入口所在页) 现有的
+--       SysTenantMenu / SysRoleMenu 授权面,用子查询继承而非硬编码 Id 清单(跨环境 Id 不同也成立)。
+--       实测该入口菜单当前 = 10 个租户 / 23 个角色;三张 IQC 业务页授权面完全一致(并集=交集)。
+--       页面可打开 ≠ 可编辑:真正的编辑权仍由后端 IqcInspBillEditGate(canEdit) 与流程 Gate 控制。
+-- 缓存:IsKeepAlive = 0。详情页是强状态页面(N1 可编辑 → N2 只读 → 退回 → N1 可编辑),
+--       不允许 KeepAlive 缓存旧的 canEdit / flowState / 项目结果。
+-- 边界:本脚本只建路由菜单与其授权,不新增业务数据、不改 qms_* / ado_s5_iqc_* 结构与数据、不改流程数据。
+-- 方言:MySQL(主库 aidopdev)。SysMenu / SysTenantMenu / SysRoleMenu 为框架表 PascalCase。
+
+-- ① 隐藏菜单:IQC检验单详情(Id=1329015010004 = iqcBase+4,Pid=来料检验目录 1322000000015)
+INSERT INTO SysMenu (Id, Pid, Type, Title, Name, Path, Component, Icon, IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, CreateTime)
+SELECT 1329015010004, 1322000000015, 2, 'IQC检验单详情', 'aidopS5IqcInspBillDetail',
+       '/aidop/s5/iqc/detail/:id', '/aidop/s5/iqc/iqcInspBillDetail', 'ele-Document',
+       0, 1, 0, 0, 45, 1, NOW()
+WHERE NOT EXISTS (SELECT 1 FROM SysMenu WHERE Id = 1329015010004);
+
+-- ② 租户可见性:继承 1329015010001 的租户面(只插缺失项)
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.TenantId), src.TenantId, 1329015010004
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysTenantMenu) base
+CROSS JOIN (SELECT DISTINCT TenantId FROM SysTenantMenu WHERE MenuId = 1329015010001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM SysTenantMenu x WHERE x.TenantId = src.TenantId AND x.MenuId = 1329015010004
+);
+
+-- ③ 角色授权:继承 1329015010001 的角色面(只插缺失项)
+INSERT INTO SysRoleMenu (Id, RoleId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.RoleId), src.RoleId, 1329015010004
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysRoleMenu) base
+CROSS JOIN (SELECT DISTINCT RoleId FROM SysRoleMenu WHERE MenuId = 1329015010001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM SysRoleMenu x WHERE x.RoleId = src.RoleId AND x.MenuId = 1329015010004
+);

+ 48 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.535.verify.sql

@@ -0,0 +1,48 @@
+-- ============================================================================
+-- 1.0.535 Batch 4B · S5 IQC 检验单详情隐藏菜单 · VERIFY
+--
+-- 执行契约(AutoVersionUpdate.RunVerifyScriptIfExists):每条语句经
+-- SqlQuerySingle<object> 取首行首列并要求 IsTruthy == true。故:
+--   ① 禁止 SET @var(返回 null 即判失败)—— 一律内联子查询;
+--   ② 每条语句返回「通过为真」的标量;
+--   ③ 文件必须以最后一条 SQL 的分号结束,末尾不得残留 comment-only 块
+--      (本文件注释内刻意不出现字面分号,避免任何 splitter 把注释切成空语句)。
+-- ============================================================================
+
+-- 菜单存在且字段口径完全正确(含 IsHide=1 / IsKeepAlive=0 两个本批关键位)
+SELECT COUNT(*) = 1 FROM SysMenu WHERE Id = 1329015010004 AND Pid = 1322000000015 AND Type = 2 AND Status = 1 AND IsHide = 1 AND IsKeepAlive = 0 AND IsAffix = 0 AND IsIframe = 0 AND OrderNo = 45 AND Title = 'IQC检验单详情' AND Name = 'aidopS5IqcInspBillDetail' AND Path = '/aidop/s5/iqc/detail/:id' AND Component = '/aidop/s5/iqc/iqcInspBillDetail';
+
+-- 路由 Path 唯一(未产生重复菜单)
+SELECT COUNT(*) = 1 FROM SysMenu WHERE Path = '/aidop/s5/iqc/detail/:id';
+
+-- 路由 Name 唯一(vue-router name 冲突会导致动态路由注册异常)
+SELECT COUNT(*) = 1 FROM SysMenu WHERE Name = 'aidopS5IqcInspBillDetail';
+
+-- 租户可见性:与 IQC 任务列表入口的租户面完全一致(继承口径成立)
+SELECT (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329015010004) = (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329015010001);
+
+-- 租户可见性:不存在「入口可见但详情不可见」的租户
+SELECT COUNT(*) = 0 FROM SysTenantMenu e WHERE e.MenuId = 1329015010001 AND NOT EXISTS (SELECT 1 FROM SysTenantMenu d WHERE d.MenuId = 1329015010004 AND d.TenantId = e.TenantId);
+
+-- 角色授权:与 IQC 任务列表入口的角色面完全一致(继承口径成立)
+SELECT (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329015010004) = (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329015010001);
+
+-- 角色授权:不存在「能进入口页但进不了详情页」的角色
+SELECT COUNT(*) = 0 FROM SysRoleMenu e WHERE e.MenuId = 1329015010001 AND NOT EXISTS (SELECT 1 FROM SysRoleMenu d WHERE d.MenuId = 1329015010004 AND d.RoleId = e.RoleId);
+
+-- 未越权:详情页授权面没有超出入口页(不得凭空多授角色)
+SELECT COUNT(*) = 0 FROM SysRoleMenu d WHERE d.MenuId = 1329015010004 AND NOT EXISTS (SELECT 1 FROM SysRoleMenu e WHERE e.MenuId = 1329015010001 AND e.RoleId = d.RoleId);
+
+-- 端到端可达性:S5 IQC 检验员角色成员可经角色到达该详情菜单
+SELECT COUNT(*) >= 1 FROM SysRoleMenu rm JOIN SysRole r ON r.Id = rm.RoleId WHERE rm.MenuId = 1329015010004 AND r.Code = 'ROLE_S5_IQC_INSPECTOR';
+
+-- 主键无重复(多行插入的 MAX(Id)+ROW_NUMBER() 写法正确性)
+SELECT COUNT(*) = COUNT(DISTINCT Id) FROM SysTenantMenu WHERE MenuId = 1329015010004;
+SELECT COUNT(*) = COUNT(DISTINCT Id) FROM SysRoleMenu WHERE MenuId = 1329015010004;
+
+-- 回归:来料检验目录下菜单由 3 增至 4,且 OrderNo 无重复
+SELECT COUNT(*) = 4 FROM SysMenu WHERE Pid = 1322000000015 AND Type = 2;
+SELECT COUNT(DISTINCT OrderNo) = COUNT(*) FROM SysMenu WHERE Pid = 1322000000015 AND Type = 2;
+
+-- 回归:三张既有 IQC 业务页仍为可见菜单(未被本脚本误伤为隐藏)
+SELECT COUNT(*) = 3 FROM SysMenu WHERE Id IN (1329015010001, 1329015010002, 1329015010003) AND IsHide = 0 AND Status = 1;

+ 145 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S5IqcGenericApprovalGuardContractTests.cs

@@ -0,0 +1,145 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
+
+/// <summary>
+/// Batch 5A 源码契约测试:S5_IQC_INSPBILL 的「通用审批旁路」必须双端封死。
+///
+/// 背景(Batch 5 实测):通用 /api/flowTask/approve 只经引擎 GetPendingTask 校验
+/// 「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户,也不在事务内,
+/// 曾以 HTTP 200 真实把 S5 检验单从 N2 推进到 N3,绕过 IqcInspBillFlowService 的
+/// 租户解析 / 业务单归属 / 节点守卫 / UseTranAsync 四层 authority。
+///
+/// 仅隐藏前端按钮不足以闭环(S7/S6 即为反例:前端 fail-closed,后端仍可手工 HTTP 直达),
+/// 故后端网关层必须 fail-closed,前端仅作一致的引导。
+///
+/// 本层沿用本仓对 FlowEngine/FlowTaskService 的既有做法——源码契约断言而非行为级集成测试:
+/// FlowEngineService 全程依赖 SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化
+/// (详见 S8ExceptionFlowTenantContextContractTests 的说明)。
+/// </summary>
+public class S5IqcGenericApprovalGuardContractTests
+{
+    private const string BizType = "S5_IQC_INSPBILL";
+    private const string Guard = "EnsureDedicatedEntryOnlyAsync";
+
+    private static string FlowTaskService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
+
+    private static string PendingList() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue"));
+
+    /// <summary>后端必须持有该 bizType 的拒绝名单,且给出引导到专用页面的文案。</summary>
+    [Fact]
+    public void Backend_DeclaresDedicatedEntryOnlyBizType()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes", s);
+        Assert.Contains($"[\"{BizType}\"]", s);
+        Assert.Contains("来料检验单详情", s);
+    }
+
+    /// <summary>权威业务类型必须从库中反查(task → instance),不得信任前端入参。</summary>
+    [Fact]
+    public void Backend_ReadsBizTypeFromDatabase_NotFromRequest()
+    {
+        var s = FlowTaskService();
+        var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n    /// <summary>");
+        Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
+        Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
+        Assert.Contains("instance.BizType", guard);
+        Assert.Contains("Oops.Oh(hint)", guard);
+        // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参
+        Assert.DoesNotContain("input.BizType", guard);
+    }
+
+    /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过 guard。</summary>
+    [Theory]
+    [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
+    [InlineData("_engine.Approve(taskId, input.Comment)")]
+    [InlineData("_engine.Reject(taskId, input.Comment)")]
+    public void Backend_GuardRunsBeforeEngineCall(string engineCall)
+    {
+        var s = FlowTaskService();
+        var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
+        Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
+        // 该引擎调用点之前最近的一次 guard 调用必须存在,且中间不得再夹一次引擎调用
+        var guardAt = s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal);
+        Assert.True(guardAt > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
+    }
+
+    /// <summary>批量端点绕过单条方法体,必须各自独立带 guard(否则批量可绕过)。</summary>
+    [Fact]
+    public void Backend_BatchEndpointsAlsoGuarded()
+    {
+        var s = FlowTaskService();
+        var batchApprove = Slice(s, "public async Task<BatchActionResult> BatchApprove", "public async Task<BatchActionResult> BatchReject");
+        Assert.Contains(Guard, batchApprove);
+        var batchReject = s[s.IndexOf("public async Task<BatchActionResult> BatchReject", StringComparison.Ordinal)..];
+        Assert.Contains(Guard, batchReject);
+    }
+
+    /// <summary>guard 只针对名单内业务,不得误伤其它通用审批(名单必须是精确匹配的字典查找)。</summary>
+    [Fact]
+    public void Backend_GuardIsAllowListedByExactBizType_NotBlanket()
+    {
+        var s = FlowTaskService();
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
+        // 不得出现「所有 Sx 业务一律拒绝」之类的宽泛匹配
+        Assert.DoesNotContain("StartsWith(\"S", s);
+        Assert.DoesNotContain("Contains(\"_INSPBILL\")", s);
+    }
+
+    /// <summary>前端待办列表必须为 S5 提供专用分支,不再落入通用 v-else 的同意/拒绝。</summary>
+    [Fact]
+    public void Frontend_PendingList_HasDedicatedS5Branch()
+    {
+        var s = PendingList();
+        Assert.Contains($"row.bizType === '{BizType}'", s);
+        Assert.Contains("goToIqc(row.bizId)", s);
+        Assert.Contains("/aidop/s5/iqc/detail/${bizId}", s);
+    }
+
+    /// <summary>S5 三个节点码必须与后端常量一致;尤其 N3 是 SQE 而非 S7 的 QE。</summary>
+    [Fact]
+    public void Frontend_UsesS5NodeCodes_NotFqcOnes()
+    {
+        var s = PendingList();
+        var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
+        Assert.Contains("'N1_INSPECT'", branch);
+        Assert.Contains("'N2_SUP_REVIEW'", branch);
+        Assert.Contains("'N3_SQE_DISPOSITION'", branch);
+        Assert.DoesNotContain("'N3_QE_DISPOSITION'", branch);   // 那是 S7 FQC 的节点码
+    }
+
+    /// <summary>未知节点必须 fail-closed(只读文案),不得回落到通用同意/拒绝。</summary>
+    [Fact]
+    public void Frontend_UnknownNode_FailsClosed()
+    {
+        var s = PendingList();
+        var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
+        Assert.Contains("<el-text v-else", branch);
+        Assert.DoesNotContain("emit('approve'", branch);
+        Assert.DoesNotContain("emit('reject'", branch);
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var a = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(a >= 0, $"未找到起点:{from}");
+        var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
+        return b > a ? src[a..b] : src[a..];
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 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)

+ 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));
+}

+ 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; }

+ 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; }
+}

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspBillDto.cs

@@ -242,4 +242,21 @@ public class IqcInspBillDetailRow
     public string? BizCode { get; set; }
     /// <summary>流程提示</summary>
     public string? FlowHint { get; set; }
+
+    // ── H 项目级检验结果(Batch 3 纯加法;N2/N3/终态同样返回,只读与否由 CanEdit 表达)──
+
+    /// <summary>
+    /// 检验单项目实例(检规快照 + 项目级结果 + 逐样本)。
+    /// 恒为数组:itemCount=0(历史单 / NO_SPEC 新单 / 命中检规但 0 明细的新单)时为空数组,不为 null。
+    /// </summary>
+    public List<IqcInspBillItemLineRow> Lines { get; set; } = [];
+
+    /// <summary>项目级结果汇总(后端实时聚合)。itemCount=0 时各计数全为 0。</summary>
+    public IqcInspBillLineSummary LineSummary { get; set; } = new();
+
+    /// <summary>
+    /// 当前用户此刻是否可编辑项目级结果。**后端是唯一 authority**,
+    /// 前端不得自行按当前节点推断(判据见 IqcInspBillEditGate)。
+    /// </summary>
+    public bool CanEdit { get; set; }
 }

+ 191 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspectionDto.cs

@@ -0,0 +1,191 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+// ════════════════════════════════════════════════════════════════════════════
+//  S5 IQC 项目级结果(Batch 3)读写契约
+//
+//  读:GET /api/S5IqcInspBill/detail 的 lines[] / lineSummary / canEdit(纯加法)
+//  写:POST /api/S5IqcInspection/save-detail
+//
+//  后端派生 / 审计字段(前端一律不得 author,传了也被忽略):
+//      sampleNumeric · sample.judgement · itemJudgement · judgementSource
+//      inspectorId · inspectionTime · lastResultBy · lastResultAt · tenantId
+// ════════════════════════════════════════════════════════════════════════════
+
+/// <summary>
+/// 检验单项目实例(检规快照 + 项目级结果)——Detail lines[] 的行。
+/// 快照字段组生成后不可变;结果字段组仅 N1 可编辑态可写。
+/// </summary>
+public class IqcInspBillItemLineRow
+{
+    /// <summary>项目实例主键(save-detail 回传用)</summary>
+    public long Id { get; set; }
+
+    /// <summary>展示序号(快照自 qms_jygfzb.xh,可空可重复,不作键)</summary>
+    public int? Seq { get; set; }
+
+    // ── 来源追溯(仅 lineage,允许悬挂,前端不展示)──
+    public long SourceSpecId { get; set; }
+    public long SourceSpecItemId { get; set; }
+
+    // ── 检规快照(只读,永不回查 S0)──
+    public string? InspectionItem { get; set; }
+    public string? InspectionStandard { get; set; }
+    public string? MethodTool { get; set; }
+    public string? ImageCategory { get; set; }
+    public string? SamplingScheme { get; set; }
+    public string? SpecRemark { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? LowerLimit { get; set; }
+
+    // ── 项目级结果 ──
+    /// <summary>NUMERIC / NON_NUMERIC;NULL = 未定(合法初态,由 N1 首次录入时选定)</summary>
+    public string? ResultType { get; set; }
+
+    /// <summary>PASS / FAIL / INVALID;NULL = 未录。后端唯一写入方</summary>
+    public string? ItemJudgement { get; set; }
+
+    /// <summary>AUTO / MANUAL;测量事实变更即强制复位为 AUTO</summary>
+    public string? JudgementSource { get; set; }
+
+    /// <summary>NON_NUMERIC 的测量真源之一;NUMERIC 恒为 NULL</summary>
+    public decimal? QualifiedQty { get; set; }
+
+    /// <summary>NON_NUMERIC 的测量真源之一;NUMERIC 恒为 NULL</summary>
+    public decimal? UnqualifiedQty { get; set; }
+
+    public string? ResultRemark { get; set; }
+    public long? LastResultBy { get; set; }
+    public DateTime? LastResultAt { get; set; }
+
+    /// <summary>NUMERIC 的测量真源;NON_NUMERIC 恒为空数组</summary>
+    public List<IqcInspBillItemSampleRow> Samples { get; set; } = [];
+}
+
+/// <summary>逐样本实测值(仅 NUMERIC 使用)。</summary>
+public class IqcInspBillItemSampleRow
+{
+    public long Id { get; set; }
+    public int SampleIndex { get; set; }
+
+    /// <summary>检验员录入原文(原样保留,供 INVALID 复盘)</summary>
+    public string? SampleValueRaw { get; set; }
+
+    /// <summary>后端解析值;不可解析 = NULL(不丢原文)</summary>
+    public decimal? SampleNumeric { get; set; }
+
+    /// <summary>PASS / FAIL / INVALID;后端唯一写入方</summary>
+    public string? Judgement { get; set; }
+
+    public string? Remark { get; set; }
+    public long? InspectorId { get; set; }
+    public DateTime? InspectionTime { get; set; }
+}
+
+/// <summary>
+/// 项目级结果汇总(后端实时聚合,不落表)。
+/// itemCount = 0 时全部为 0(LEGACY MODE:历史单 / NO_SPEC 新单 / MATCHED+0 明细新单同一语义)。
+/// </summary>
+public class IqcInspBillLineSummary
+{
+    /// <summary>项目实例总数;0 ⇒ LEGACY MODE</summary>
+    public int ItemCount { get; set; }
+
+    /// <summary>已给出 PASS/FAIL 的项目数(可提交口径)</summary>
+    public int JudgedCount { get; set; }
+
+    public int PassCount { get; set; }
+    public int FailCount { get; set; }
+    public int InvalidCount { get; set; }
+
+    /// <summary>itemJudgement 为 NULL 的项目数</summary>
+    public int UnjudgedCount { get; set; }
+}
+
+// ────────────────────────────── save-detail 入参 ──────────────────────────────
+
+/// <summary>
+/// 保存项目级检验结果(草稿,**不推进流程**)。
+/// item 级增量:未出现在 items 中的项目完全不动。
+/// item 内样本全量替换:携带的 samples 即该项目的完整新集合。
+/// </summary>
+public class IqcSaveDetailInput
+{
+    /// <summary>检验单 id(qms_qcp_inspbill.id)</summary>
+    public long BillId { get; set; }
+
+    public List<IqcSaveDetailItemInput> Items { get; set; } = [];
+}
+
+/// <summary>单个项目的结果入参。</summary>
+public class IqcSaveDetailItemInput
+{
+    /// <summary>项目实例 id;必须属当前租户 + 当前 bill,否则整单 400</summary>
+    public long ItemId { get; set; }
+
+    /// <summary>
+    /// 录入类型 NUMERIC / NON_NUMERIC。
+    /// 与库中现值不同(含 NULL→非 NULL)视为类型切换,会清空该项目的全部旧测量事实。
+    /// 不传(null)表示沿用库中现值。
+    /// </summary>
+    public string? ResultType { get; set; }
+
+    /// <summary>NON_NUMERIC 测量真源;NUMERIC 传值 → 400(禁双真源)</summary>
+    public decimal? QualifiedQty { get; set; }
+
+    /// <summary>NON_NUMERIC 测量真源;NUMERIC 传值 → 400(禁双真源)</summary>
+    public decimal? UnqualifiedQty { get; set; }
+
+    public string? ResultRemark { get; set; }
+
+    /// <summary>
+    /// 人工判定,仅允许 PASS / FAIL(不得人工设 INVALID)。
+    /// 传值 ⇒ judgementSource=MANUAL;不传 ⇒ 按测量真源 AUTO 重算。
+    /// </summary>
+    public string? ManualJudgement { get; set; }
+
+    /// <summary>
+    /// NUMERIC 测量真源,**该项目的完整新集合**(未出现的旧样本会被删除)。
+    /// NON_NUMERIC 携带 → 400。
+    /// </summary>
+    public List<IqcSaveDetailSampleInput>? Samples { get; set; }
+}
+
+/// <summary>单个样本入参。</summary>
+public class IqcSaveDetailSampleInput
+{
+    /// <summary>样本序号,必须 &gt;= 1 且在同一项目内不重复</summary>
+    public int SampleIndex { get; set; }
+
+    /// <summary>录入原文,非空(空值语义 = 该样本不存在 ⇒ 请从数组中移除,而不是传空串)</summary>
+    public string? SampleValueRaw { get; set; }
+
+    public string? Remark { get; set; }
+}
+
+// ────────────────────────────── save-detail 出参 ──────────────────────────────
+
+/// <summary>保存结果。回传受影响项目的最终判定与实时汇总,供前端免二次拉取。</summary>
+public class IqcSaveDetailOutput
+{
+    public bool Ok { get; set; } = true;
+
+    /// <summary>本次实际更新的项目数</summary>
+    public int SavedItems { get; set; }
+
+    /// <summary>本次写入的样本行数</summary>
+    public int SavedSamples { get; set; }
+
+    /// <summary>本次删除的样本行数(含类型切换清理与全量替换剪枝)</summary>
+    public int DeletedSamples { get; set; }
+
+    /// <summary>因类型切换而清空过测量事实的项目 id</summary>
+    public List<long> ClearedByTypeChange { get; set; } = [];
+
+    /// <summary>因测量事实变更而被作废的旧 MANUAL 判定所属项目 id</summary>
+    public List<long> ManualInvalidated { get; set; } = [];
+
+    public IqcInspBillLineSummary LineSummary { get; set; } = new();
+
+    /// <summary>本次涉及项目的最终结果(含重算后的 judgement / source)</summary>
+    public List<IqcInspBillItemLineRow> Items { get; set; } = [];
+}

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcTaskEntryDto.cs

@@ -80,4 +80,15 @@ public class IqcGenerateInspBillOutput
 
     /// <summary>候选检规数量(可观测性用;0 表示该物料无检规映射)。</summary>
     public int SpecCandidateCount { get; set; }
+
+    /// <summary>
+    /// B2:本单实际冻结的检规明细条数(写入 ado_s5_iqc_insp_bill_item 的行数)。
+    ///
+    /// 与 <see cref="SpecStatus"/> 正交,不可互相推断:
+    ///   · SpecStatus=MATCHED* 且 ItemCount&gt;0 → 已冻结 N 条检验项目
+    ///   · SpecStatus=MATCHED* 且 ItemCount=0 → 合法状态:命中检规,但该检规未维护明细
+    ///   · SpecStatus=NO_SPEC / NO_EFFECTIVE_SPEC / AMBIGUOUS → 无快照对象,恒为 0
+    /// 0 一律不视为错误(S5 刻意放宽,见 S5IqcSpecResolver.cs:76-79)。
+    /// </summary>
+    public int ItemCount { get; set; }
 }

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillEditGate.cs

@@ -0,0 +1,98 @@
+using Admin.NET.Plugin.ApprovalFlow;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 检验单「项目级结果是否可编辑」的唯一判据(Batch 3)。
+///
+/// 单独成类而非散落在 Service 内,目的是让编辑授权只有一个真源:
+/// Detail 的 canEdit、save-detail 的门禁都必须走这里,不允许各处自行判 currentNode。
+///
+/// 【判据】
+///   ① bill 必须存在且属当前租户(带租户谓词,见 tenant 边界说明)
+///   ② 取该 bill 最新流程实例(BizType + BizId)
+///      · 实例不存在(尚未起流)        → 具备检验员角色即可编辑
+///      · 实例非 Running(终态/拒绝/撤销)→ 只读
+///      · Running 且当前用户有 N1_INSPECT 待办 → 可编辑
+///      · 其余(N2 / GW_PD / N3 / end;或在 N1 但待办属他人)→ 只读
+///
+/// 【为什么必须有「实例不存在」分支】
+///   GenerateInspBill **不起流**,流程是 submit-result 惰性 StartFlow
+///   (IqcInspBillFlowService.cs:95-107)。新生成的检验单没有实例、也没有 N1 待办,
+///   若只认「有实例且在 N1」,检验员将永远无法在提交前录入结果,本能力直接失效。
+///   该分支与既有 IqcInspBillFlowService.BuildStateAsync 对 CanSubmitResult 的判法
+///   (实例为空时 = HasRole(RoleInspectorId))完全一致 —— 保证「能存」与「能提交」
+///   权限口径同一,不会出现可存不可提交、或可提交不可存的矛盾。
+///
+/// 【为什么不叠加 jyfzr == 当前用户】
+///   jyfzr 在报检分录上、由认领/调配写入;ApprovalFlow 的 N1 受让人由角色成员解析,
+///   两者是不同人群。同时要求会制造与 S7 FQC 同类的审批人死结。
+///   N1 待办本身已是更强的授权凭据,故刻意不加该条件。
+///
+/// 【租户边界的结构性限制(实测)】
+///   ApprovalFlowInstance / ApprovalFlowTask **没有 TenantId 列**(只有 OrgId / CreateUserId),
+///   因此流程查询无法直接加租户谓词。本类的做法是:先用带 tenant_id 谓词的 bill 查询确认
+///   billId 属当前租户,再以该 billId 作 BizId 查流程 —— 租户约束经 bill 传递。
+///   这是该框架表结构下可达的最强隔离。
+/// </summary>
+public class IqcInspBillEditGate : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public IqcInspBillEditGate(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// 该检验单当前是否允许由**当前用户**编辑项目级结果。
+    /// billId 不属当前租户时返回 false(不区分“不存在”与“属他人租户”,避免存在性泄漏)。
+    /// </summary>
+    public async Task<bool> CanEditAsync(long billId, long tenantId)
+    {
+        if (billId <= 0 || tenantId <= 0) return false;
+
+        // ① 租户归属(唯一的租户谓词落点;后续流程查询经 billId 传递该约束)
+        var owns = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId",
+            new SugarParameter("@id", billId),
+            new SugarParameter("@TenantId", tenantId));
+        if (owns == 0) return false;
+
+        // ② 最新流程实例
+        var inst = await _db.Queryable<ApprovalFlowInstance>()
+            .ClearFilter()
+            .Where(x => x.BizType == IqcInspBillFlowConst.BizType && x.BizId == billId)
+            .OrderByDescending(x => x.Id)
+            .FirstAsync();
+
+        // 尚未起流:检验员可先录入,提交时再惰性起流
+        if (inst == null)
+            return await HasInspectorRoleAsync();
+
+        // 已结束(Approved / Rejected / Cancelled / Terminated)→ 只读
+        if (inst.Status != FlowInstanceStatusEnum.Running) return false;
+
+        // Running:必须当前用户在 N1_INSPECT 有待办
+        var userId = _userManager.UserId;
+        var myTask = await _db.Queryable<ApprovalFlowTask>()
+            .ClearFilter()
+            .Where(x => x.InstanceId == inst.Id
+                     && x.AssigneeId == userId
+                     && x.Status == FlowTaskStatusEnum.Pending)
+            .FirstAsync();
+
+        return myTask != null && myTask.NodeId == IqcInspBillFlowConst.NodeInspect;
+    }
+
+    /// <summary>当前用户是否具备 S5 IQC 检验员角色(跨数据范围,与 Flow 服务同口径)。</summary>
+    private async Task<bool> HasInspectorRoleAsync()
+    {
+        var userId = _userManager.UserId;
+        return await _db.Queryable<SysUserRole>()
+            .ClearFilter()
+            .AnyAsync(x => x.UserId == userId && x.RoleId == IqcInspBillFlowConst.RoleInspectorId);
+    }
+}

+ 50 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs

@@ -29,16 +29,21 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
     private readonly UserManager _userManager;
     private readonly IqcWmsResultPushService _wmsPush;
 
+    /// <summary>Batch 3:项目级结果读取器,仅用于 submit-result 的只读前置校验。</summary>
+    private readonly IqcInspBillItemReader _itemReader;
+
     public IqcInspBillFlowService(
         ISqlSugarClient db,
         FlowEngineService flowEngine,
         UserManager userManager,
-        IqcWmsResultPushService wmsPush)
+        IqcWmsResultPushService wmsPush,
+        IqcInspBillItemReader itemReader)
     {
         _db = db;
         _flowEngine = flowEngine;
         _userManager = userManager;
         _wmsPush = wmsPush;
+        _itemReader = itemReader;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -86,6 +91,10 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
             if (input.Clfs is not (0 or 1 or 2)) throw Oops.Oh("判定不合格时处理方式 clfs 必填且只能为 0(让步接收)/1(挑选)/2(报废)");
         }
 
+        // Batch 3:项目级前置校验。刻意放在事务之前、Header 写入与流程推进之前 ——
+        // 纯只读 SELECT,失败直接抛,零写入残留、无需回滚。
+        await ValidateItemResultsBeforeSubmitAsync(input.Id, input.Pd);
+
         var inst = await GetLatestInstanceAsync(input.Id);
         if (inst != null && inst.Status != FlowInstanceStatusEnum.Running)
             throw Oops.Oh("该检验单流程已结束,无法再提交检验结果");
@@ -274,6 +283,46 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
 
     // ───────────────────────── helpers ─────────────────────────
 
+    /// <summary>
+    /// Batch 3:item-mode 提交前置校验(**纯只读**,不写任何表、不推进流程)。
+    ///
+    /// 【LEGACY MODE · itemCount = 0】
+    ///   涵盖历史单、NO_SPEC 新单、命中检规但该检规 0 明细的新单 —— 三者同一语义。
+    ///   立即短路返回,**不追加任何项目/样本/类型校验**,行为与 Batch 3 之前完全一致。
+    ///   判据只看数据(COUNT),不看日期 / 单号 / 版本。
+    ///
+    /// 【ITEM MODE · itemCount &gt; 0】两条硬规则:
+    ///   ① 每个项目必须已给出 PASS / FAIL —— NULL(未录)与 INVALID(系统无法判定)均阻断提交;
+    ///   ② 存在 FAIL 项目时,整单判定不得为「合格」(pd=0) —— HARD ERROR,不是 warning,
+    ///      杜绝「项目不合格 + 整单合格」流入主管审核。
+    ///
+    /// 【刻意不做】不由项目结果反推 pd / dhsl / bhgsl / clfs —— Header 语义保持既有,本方法只校验。
+    /// 全部项目 PASS 而用户提交 pd=1(不合格)本批**不反向强制纠正**,按既有 Header 语义放行。
+    /// </summary>
+    private async Task ValidateItemResultsBeforeSubmitAsync(long billId, int pd)
+    {
+        var tid = ResolveTenantOrThrow();
+
+        var itemCount = await _itemReader.CountItemsAsync(billId, tid);
+        if (itemCount == 0) return; // LEGACY MODE:原样放行
+
+        var lines = await _itemReader.LoadLinesAsync(billId, tid);
+
+        var unready = lines.Where(l => !IqcInspectionJudge.IsSubmittable(l.ItemJudgement)).ToList();
+        if (unready.Count > 0)
+        {
+            var names = string.Join("、", unready.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
+            throw Oops.Oh($"INSPECTION_INCOMPLETE:仍有 {unready.Count} 个检验项目未给出有效判定({names}),无法提交");
+        }
+
+        var failed = lines.Where(l => l.ItemJudgement == IqcJudgement.Fail).ToList();
+        if (pd == 0 && failed.Count > 0)
+        {
+            var names = string.Join("、", failed.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
+            throw Oops.Oh($"存在 {failed.Count} 个不合格检验项目({names}),整单判定不能为合格");
+        }
+    }
+
     /// <summary>校验检验单存在并返回 FBILLNO。</summary>
     private async Task<string> EnsureBillExistsAsync(long id)
     {

+ 141 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillItemReader.cs

@@ -0,0 +1,141 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 项目实例 + 逐样本 的只读读取器(Batch 3)。
+///
+/// 单独成类的原因:Detail(GET)与 save-detail(POST 回执)需要**完全相同**的行形状与汇总口径,
+/// 放一处可避免两边 SQL 漂移。本类只读,不含任何写入。
+///
+/// 【租户边界】所有查询都显式带 tenant_id 谓词,且首列即 tenant_id(命中 uk/idx 最左前缀)。
+/// 绝不依赖 billId / itemId 的全局唯一性作为隔离手段。
+/// </summary>
+public class IqcInspBillItemReader : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public IqcInspBillItemReader(ISqlSugarClient db) => _db = db;
+
+    /// <summary>
+    /// 该检验单的项目实例条数。0 ⇒ LEGACY MODE(历史单 / NO_SPEC 新单 / MATCHED+0 明细新单,同一语义)。
+    /// 判据只看数据,不看日期 / 单号 / 版本。
+    /// </summary>
+    public async Task<int> CountItemsAsync(long billId, long tenantId)
+        => await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM ado_s5_iqc_insp_bill_item WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId",
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@BillId", billId));
+
+    /// <summary>
+    /// 载入该检验单的全部项目实例(含其样本,按 seq/id 与 sample_index 升序)。
+    /// itemCount=0 时返回空列表(不是 null)。
+    /// </summary>
+    public async Task<List<IqcInspBillItemLineRow>> LoadLinesAsync(long billId, long tenantId)
+    {
+        var pars = new List<SugarParameter>
+        {
+            new("@TenantId", tenantId),
+            new("@BillId", billId),
+        };
+
+        var lines = await _db.Ado.SqlQueryAsync<IqcInspBillItemLineRow>(
+            """
+            SELECT
+                id                  AS Id,
+                seq                 AS Seq,
+                source_spec_id      AS SourceSpecId,
+                source_spec_item_id AS SourceSpecItemId,
+                inspection_item     AS InspectionItem,
+                inspection_standard AS InspectionStandard,
+                method_tool         AS MethodTool,
+                image_category      AS ImageCategory,
+                sampling_scheme     AS SamplingScheme,
+                spec_remark         AS SpecRemark,
+                upper_limit         AS UpperLimit,
+                lower_limit         AS LowerLimit,
+                result_type         AS ResultType,
+                item_judgement      AS ItemJudgement,
+                judgement_source    AS JudgementSource,
+                qualified_qty       AS QualifiedQty,
+                unqualified_qty     AS UnqualifiedQty,
+                result_remark       AS ResultRemark,
+                last_result_by      AS LastResultBy,
+                last_result_at      AS LastResultAt
+            FROM ado_s5_iqc_insp_bill_item
+            WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
+            ORDER BY seq, id
+            """,
+            pars);
+
+        if (lines.Count == 0) return lines;
+
+        // 样本按单一次取(反规范化的 inspection_bill_id 使其免 JOIN),再按 item 分组
+        var samples = await _db.Ado.SqlQueryAsync<SampleRow>(
+            """
+            SELECT
+                id               AS Id,
+                item_id          AS ItemId,
+                sample_index     AS SampleIndex,
+                sample_value_raw AS SampleValueRaw,
+                sample_numeric   AS SampleNumeric,
+                judgement        AS Judgement,
+                remark           AS Remark,
+                inspector_id     AS InspectorId,
+                inspection_time  AS InspectionTime
+            FROM ado_s5_iqc_insp_bill_item_sample
+            WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
+            ORDER BY item_id, sample_index
+            """,
+            pars);
+
+        var byItem = samples.GroupBy(s => s.ItemId).ToDictionary(g => g.Key, g => g.ToList());
+        foreach (var line in lines)
+        {
+            if (!byItem.TryGetValue(line.Id, out var list)) continue;
+            line.Samples = list.Select(s => new IqcInspBillItemSampleRow
+            {
+                Id = s.Id,
+                SampleIndex = s.SampleIndex,
+                SampleValueRaw = s.SampleValueRaw,
+                SampleNumeric = s.SampleNumeric,
+                Judgement = s.Judgement,
+                Remark = s.Remark,
+                InspectorId = s.InspectorId,
+                InspectionTime = s.InspectionTime,
+            }).ToList();
+        }
+        return lines;
+    }
+
+    /// <summary>实时聚合汇总(不落表,避免与 item 行形成第二个判定真源)。</summary>
+    public static IqcInspBillLineSummary Summarize(IReadOnlyList<IqcInspBillItemLineRow> lines)
+    {
+        var s = new IqcInspBillLineSummary { ItemCount = lines.Count };
+        foreach (var l in lines)
+        {
+            switch (l.ItemJudgement)
+            {
+                case IqcJudgement.Pass: s.PassCount++; s.JudgedCount++; break;
+                case IqcJudgement.Fail: s.FailCount++; s.JudgedCount++; break;
+                case IqcJudgement.Invalid: s.InvalidCount++; break;
+                default: s.UnjudgedCount++; break;
+            }
+        }
+        return s;
+    }
+
+    /// <summary>样本原始行(含 ItemId 用于分组,不外泄到 API 契约)。</summary>
+    private sealed class SampleRow
+    {
+        public long Id { get; set; }
+        public long ItemId { get; set; }
+        public int SampleIndex { get; set; }
+        public string? SampleValueRaw { get; set; }
+        public decimal? SampleNumeric { get; set; }
+        public string? Judgement { get; set; }
+        public string? Remark { get; set; }
+        public long? InspectorId { get; set; }
+        public DateTime? InspectionTime { get; set; }
+    }
+}

+ 21 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillService.cs

@@ -25,10 +25,22 @@ public class IqcInspBillService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
 
-    public IqcInspBillService(ISqlSugarClient db, UserManager userManager)
+    /// <summary>Batch 3:项目实例 + 逐样本 的只读读取器(与 save-detail 回执共用同一行形状与汇总口径)。</summary>
+    private readonly IqcInspBillItemReader _itemReader;
+
+    /// <summary>Batch 3:编辑授权唯一判据,供详情回传 canEdit(前端不得自行按节点推断)。</summary>
+    private readonly IqcInspBillEditGate _editGate;
+
+    public IqcInspBillService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        IqcInspBillItemReader itemReader,
+        IqcInspBillEditGate editGate)
     {
         _db = db;
         _userManager = userManager;
+        _itemReader = itemReader;
+        _editGate = editGate;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -249,6 +261,14 @@ public class IqcInspBillService : IDynamicApiController, ITransient
         row.BizId = row.Id;
         row.BizCode = row.BillNo;
         row.FlowHint = "流程将在后续 C'/D'/E' 批次接入";
+
+        // ── Batch 3 纯加法:项目级结果 ──
+        // 与 Header 同一个已解析租户,谓词 tenant_id + inspection_bill_id(首列即租户,命中 uk/idx 最左前缀)。
+        // 只读快照,绝不回查 S0 的 qms_jygfzb —— 否则 S0 改检规会篡改历史检验单的展示。
+        // N2 / N3 / 终态同样返回(Backend 不因节点隐藏历史测量事实),是否允许编辑由 CanEdit 表达。
+        row.Lines = await _itemReader.LoadLinesAsync(id, tenantId);
+        row.LineSummary = IqcInspBillItemReader.Summarize(row.Lines);
+        row.CanEdit = await _editGate.CanEditAsync(id, tenantId);
         return row;
     }
 }

+ 139 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionJudge.cs

@@ -0,0 +1,139 @@
+using System.Globalization;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>录入类型(ado_s5_iqc_insp_bill_item.result_type 的合法值,与 ck_s5_iqc_item_result_type 对齐)。</summary>
+public static class IqcResultType
+{
+    public const string Numeric = "NUMERIC";
+    public const string NonNumeric = "NON_NUMERIC";
+
+    public static bool IsValid(string? v) => v == Numeric || v == NonNumeric;
+}
+
+/// <summary>
+/// 判定值(与 ck_s5_iqc_item_judgement / ck_s5_iqc_sample_judgement 对齐)。
+/// 刻意**不含** PENDING —— Batch 1 已从枚举中移除:未录 = NULL,已录 = PASS/FAIL/INVALID 三值封闭。
+/// </summary>
+public static class IqcJudgement
+{
+    public const string Pass = "PASS";
+    public const string Fail = "FAIL";
+
+    /// <summary>系统无法形成有效测量判定(限值缺失 / 值不可解析)。**不允许人工设置**。</summary>
+    public const string Invalid = "INVALID";
+}
+
+/// <summary>判定出处(与 ck_s5_iqc_item_jsrc 对齐)。</summary>
+public static class IqcJudgementSource
+{
+    /// <summary>由测量真源实时重算,每次保存无条件覆盖。</summary>
+    public const string Auto = "AUTO";
+
+    /// <summary>检验员显式结论;测量事实一旦变更即强制复位为 AUTO。</summary>
+    public const string Manual = "MANUAL";
+}
+
+/// <summary>
+/// S5 IQC 项目级判定引擎(Batch 3,纯函数,后端唯一判定真源)。
+///
+/// 只依据「快照冻结的限值」+「实录测量事实」判定,**绝不回查 qms_jygfzb**(否则 S0 改检规会篡改历史判定)。
+/// 前端传入的任何 judgement / judgementSource 一律忽略。
+///
+/// 【单一真源】
+///   NUMERIC      → 测量真源 = ado_s5_iqc_insp_bill_item_sample 行集;item 上两个 qty 必须为 NULL
+///   NON_NUMERIC  → 测量真源 = item.qualified_qty / unqualified_qty;该 item 不得有 sample 行
+///
+/// 【与 S6 的差异(刻意)】
+///   ① S6 的 NON_NUMERIC 是逐样本 OK/NG 模型(ado_s6_process_inspection_result_item.actual_non_numeric),
+///      IQC 是 item 级计数模型且禁止样本行 ⇒ S6 该分支代码不可迁移;
+///   ② S6 的 AggregateItem / AllItemsCompleted 依赖快照的 sample_count(要求样本量),
+///      IQC 无该数据来源(qms_jygfzb 无对应列)⇒ 完整性只能基于 item_judgement 是否已定,
+///      不引入「要求样本量」概念;
+///   ③ NUMERIC 逐样本判定的四个分支与 S6ProcessInspectionJudge.JudgeSample 语义一致,按同规则实现。
+/// </summary>
+public static class IqcInspectionJudge
+{
+    /// <summary>
+    /// 解析十进制数(限值与样本值共用)。空/空白/不可解析 → null。
+    /// 口径沿用 S6ProcessInspectionJudge:NumberStyles.Any + InvariantCulture + Trim,
+    /// 避免服务器区域设置漂移导致小数点/千分位误判(刻意不采用 S7 的默认 Culture 写法)。
+    /// </summary>
+    public static decimal? ParseNumeric(string? raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw)) return null;
+        return decimal.TryParse(raw.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
+    }
+
+    /// <summary>
+    /// NUMERIC 逐样本判定。只用快照冻结的 upper_limit / lower_limit,**绝不解析 inspection_standard 自由文本**。
+    ///
+    ///   上下限均缺失            → INVALID(无判据,不可默认 PASS)
+    ///   样本值不可解析          → INVALID(保留 raw 供复盘)
+    ///   双侧齐备                → lower ≤ v ≤ upper
+    ///   仅上限                  → v ≤ upper
+    ///   仅下限                  → v ≥ lower
+    /// </summary>
+    public static string JudgeSampleNumeric(string? actualRaw, string? lowerLimitRaw, string? upperLimitRaw)
+    {
+        var lo = ParseNumeric(lowerLimitRaw);
+        var up = ParseNumeric(upperLimitRaw);
+        if (lo == null && up == null) return IqcJudgement.Invalid;
+
+        var actual = ParseNumeric(actualRaw);
+        if (actual == null) return IqcJudgement.Invalid;
+
+        var ok = (lo == null || actual.Value >= lo.Value)
+              && (up == null || actual.Value <= up.Value);
+        return ok ? IqcJudgement.Pass : IqcJudgement.Fail;
+    }
+
+    /// <summary>
+    /// NUMERIC 项目级聚合(由该 item 全部样本的逐样本判定得出)。
+    ///
+    ///   无样本        → null(未录;**不可自动 PASS**)
+    ///   任一 INVALID  → INVALID
+    ///   任一 FAIL     → FAIL
+    ///   全部 PASS     → PASS
+    ///
+    /// 刻意不引入「录满样本量才算完成」——IQC 无 required_sample_qty 数据来源。
+    /// </summary>
+    public static string? AggregateNumericItem(IReadOnlyList<string?>? sampleJudgements)
+    {
+        if (sampleJudgements == null || sampleJudgements.Count == 0) return null;
+        if (sampleJudgements.Any(j => j == IqcJudgement.Invalid)) return IqcJudgement.Invalid;
+        if (sampleJudgements.Any(j => j == IqcJudgement.Fail)) return IqcJudgement.Fail;
+        if (sampleJudgements.All(j => j == IqcJudgement.Pass)) return IqcJudgement.Pass;
+        // 出现非三值枚举的脏值(理论不可达:judgement 由本引擎唯一写入 + DB CHECK 兜底)
+        return IqcJudgement.Invalid;
+    }
+
+    /// <summary>
+    /// NON_NUMERIC 项目级判定(直接消费计数真源,不经过样本)。
+    ///
+    ///   unqualified &gt; 0                        → FAIL
+    ///   unqualified = 0 且 qualified 非空       → PASS
+    ///   其余(数据不足:任一为空)              → null(未录)
+    ///
+    /// 「数据不足」刻意判为 null 而非 INVALID:INVALID 的语义是「系统无法形成有效测量判定」,
+    /// 而计数为空只是「还没录」,与 NUMERIC 无样本时返回 null 保持一致。
+    /// **绝不**根据 Header 报检数量自动补齐 qualified。
+    /// </summary>
+    public static string? JudgeNonNumericItem(decimal? qualifiedQty, decimal? unqualifiedQty)
+    {
+        if (unqualifiedQty == null || qualifiedQty == null) return null;
+        if (unqualifiedQty.Value > 0m) return IqcJudgement.Fail;
+        return IqcJudgement.Pass;
+    }
+
+    /// <summary>
+    /// 人工判定合法性。只允许 PASS / FAIL —— 不得人工设置 INVALID
+    /// (INVALID 表示系统无法判定,是派生事实,不是人可以宣称的结论)。
+    /// </summary>
+    public static bool IsValidManualJudgement(string? judgement)
+        => judgement == IqcJudgement.Pass || judgement == IqcJudgement.Fail;
+
+    /// <summary>提交放行判据:只有已给出 PASS / FAIL 的项目才算完成(NULL 与 INVALID 均不放行)。</summary>
+    public static bool IsSubmittable(string? itemJudgement)
+        => itemJudgement == IqcJudgement.Pass || itemJudgement == IqcJudgement.Fail;
+}

+ 434 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionService.cs

@@ -0,0 +1,434 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 项目级检验结果录入(Batch 3)。只保存结果,**不推进流程**。
+///
+/// 【职责边界】
+///   save-detail   = 保存 item-level 测量事实与判定(草稿,可反复保存)
+///   submit-result = 校验 + 写整单结论 + 推进 N1→N2(仍在 IqcInspBillFlowService,本类不碰)
+///   ⇒ 保存 ≠ 审批,两者不可混合(原则:save-detail 绝不 Approve)
+///
+/// 【单一真源】
+///   NUMERIC     → 测量真源 = ado_s5_iqc_insp_bill_item_sample;item 两个 qty 必须 NULL
+///   NON_NUMERIC → 测量真源 = item.qualified_qty / unqualified_qty;该 item 不得有样本行
+///   违反者一律 400,不静默存双真源(DB 侧另有 ck_s5_iqc_item_truth 兜底)
+///
+/// 【编辑授权】唯一判据是 IqcInspBillEditGate.CanEditAsync(N1 可编辑 / N2·N3·Done 只读)。
+///   刻意不叠加 jyfzr==当前用户(与 S7 FqcInspectionService 的做法分歧,理由见 Gate 注释)。
+///   注意:S7 的 save-detail 完全不查流程态,只要是 jyfzr 本人在 N2/N3/终态仍可改明细
+///   (FqcInspectionService.cs:104-155)—— 这是已上线的口子,本类刻意不复制。
+///
+/// 【前端不得 author 的字段】sampleNumeric · sample.judgement · itemJudgement · judgementSource
+///   · inspectorId · inspectionTime · lastResultBy · lastResultAt · tenantId
+///   —— 即使 DTO 里出现也一律忽略/拒绝,全部由后端派生。
+/// </summary>
+[ApiDescriptionSettings(Order = 312, Description = "来料检验项目录入")]
+[Route("api/S5IqcInspection")]
+[NonUnify]
+public class IqcInspectionService : IDynamicApiController, ITransient
+{
+    /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
+    private const long MainTenantId = 1300000000001L;
+
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly IqcInspBillEditGate _editGate;
+    private readonly IqcInspBillItemReader _reader;
+
+    public IqcInspectionService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        IqcInspBillEditGate editGate,
+        IqcInspBillItemReader reader)
+    {
+        _db = db;
+        _userManager = userManager;
+        _editGate = editGate;
+        _reader = reader;
+    }
+
+    /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选租户 → 拒绝;不读前端 tenantId。</summary>
+    private long ResolveTenantOrThrow()
+    {
+        var tid = _userManager.TenantId;
+        if (tid <= 0) throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+        if (_userManager.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+        return tid;
+    }
+
+    /// <summary>
+    /// 保存项目级检验结果。item 级增量 + item 内样本全量替换;全事务,任一项失败整体回滚。
+    /// </summary>
+    [DisplayName("保存来料检验项目结果")]
+    [HttpPost("save-detail")]
+    public async Task<IqcSaveDetailOutput> SaveDetail([FromBody] IqcSaveDetailInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.BillId <= 0) throw Oops.Oh("检验单 id 非法");
+        if (input.Items == null || input.Items.Count == 0) throw Oops.Oh("请至少提交一个检验项目");
+
+        // ── ① 单据归属(租户谓词) ──
+        var owns = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId",
+            new SugarParameter("@id", input.BillId),
+            new SugarParameter("@TenantId", tenantId));
+        if (owns == 0) throw Oops.Oh("检验单不存在或不属于当前租户");
+
+        // ── ② 编辑授权(唯一判据) ──
+        if (!await _editGate.CanEditAsync(input.BillId, tenantId))
+            throw Oops.Oh("当前节点不允许修改检验明细");
+
+        // ── ③ itemId 重复检测 ──
+        var dup = input.Items.GroupBy(x => x.ItemId).FirstOrDefault(g => g.Count() > 1);
+        if (dup != null) throw Oops.Oh($"检验项目 {dup.Key} 在本次请求中重复提交");
+
+        // ── ④ 只认本单本租户的项目(跨 bill / 跨租户注入在进入事务前即被挡下) ──
+        var cfgList = await _db.Ado.SqlQueryAsync<ItemCfgRow>(
+            """
+            SELECT id AS Id, result_type AS ResultType, upper_limit AS UpperLimit, lower_limit AS LowerLimit,
+                   qualified_qty AS QualifiedQty, unqualified_qty AS UnqualifiedQty,
+                   item_judgement AS ItemJudgement, judgement_source AS JudgementSource
+            FROM ado_s5_iqc_insp_bill_item
+            WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BillId", input.BillId),
+            });
+        var cfg = cfgList.ToDictionary(x => x.Id);
+
+        foreach (var it in input.Items)
+        {
+            if (it.ItemId <= 0) throw Oops.Oh("检验项目 id 非法");
+            if (!cfg.ContainsKey(it.ItemId))
+                throw Oops.Oh($"检验明细 {it.ItemId} 不属于该检验单");
+        }
+
+        // ── ⑤ 逐项语义校验(全部在事务外完成 ⇒ 失败时 0 partial writes) ──
+        var plans = new List<ItemPlan>(input.Items.Count);
+        foreach (var it in input.Items)
+        {
+            var db = cfg[it.ItemId];
+            var plan = BuildPlan(it, db);
+            plans.Add(plan);
+        }
+
+        // ── ⑥ 单事务写入 ──
+        var savedItems = 0;
+        var savedSamples = 0;
+        var deletedSamples = 0;
+        var clearedByTypeChange = new List<long>();
+        var manualInvalidated = new List<long>();
+        var now = DateTime.Now;
+        var uid = _userManager.UserId;
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            foreach (var p in plans)
+            {
+                var itemPars = new List<SugarParameter>
+                {
+                    new("@TenantId", tenantId),
+                    new("@BillId", input.BillId),
+                    new("@ItemId", p.ItemId),
+                };
+
+                // 类型切换:清空该项目的**全部**旧测量事实(两类真源都清),避免残留第二真源
+                if (p.TypeChanged)
+                {
+                    deletedSamples += await _db.Ado.ExecuteCommandAsync(
+                        "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
+                        itemPars);
+                    clearedByTypeChange.Add(p.ItemId);
+                }
+
+                // NUMERIC:样本全量替换(携带 samples 时先删后插;未携带则不动样本集)
+                if (p.EffectiveResultType == IqcResultType.Numeric && p.Samples != null)
+                {
+                    if (!p.TypeChanged)
+                    {
+                        deletedSamples += await _db.Ado.ExecuteCommandAsync(
+                            "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
+                            itemPars);
+                    }
+
+                    foreach (var s in p.Samples)
+                    {
+                        savedSamples += await _db.Ado.ExecuteCommandAsync(
+                            """
+                            INSERT INTO ado_s5_iqc_insp_bill_item_sample (
+                                tenant_id, inspection_bill_id, item_id, sample_index,
+                                sample_value_raw, sample_numeric, judgement, remark,
+                                inspector_id, inspection_time
+                            ) VALUES (
+                                @TenantId, @BillId, @ItemId, @Idx,
+                                @Raw, @Num, @Judge, @Remark,
+                                @Uid, @Now
+                            )
+                            """,
+                            new List<SugarParameter>
+                            {
+                                new("@TenantId", tenantId),
+                                new("@BillId", input.BillId),
+                                new("@ItemId", p.ItemId),
+                                new("@Idx", s.SampleIndex),
+                                new("@Raw", s.Raw),
+                                new("@Num", (object?)s.Numeric ?? DBNull.Value),
+                                new("@Judge", s.Judgement),
+                                new("@Remark", (object?)s.Remark ?? DBNull.Value),
+                                new("@Uid", (object?)uid ?? DBNull.Value),
+                                new("@Now", now),
+                            });
+                    }
+                }
+
+                // NON_NUMERIC:确保无样本残留(类型切换已删;此处兜住历史脏数据)
+                if (p.EffectiveResultType == IqcResultType.NonNumeric && !p.TypeChanged)
+                {
+                    deletedSamples += await _db.Ado.ExecuteCommandAsync(
+                        "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
+                        itemPars);
+                }
+
+                // 判定:AUTO 由真源实时重算;MANUAL 只覆盖 verdict、不改 measurement
+                string? finalJudgement;
+                string? finalSource;
+                if (p.ManualJudgement != null)
+                {
+                    finalJudgement = p.ManualJudgement;
+                    finalSource = IqcJudgementSource.Manual;
+                }
+                else if (p.KeepExistingVerdict)
+                {
+                    // 未改测量事实且原判定为 MANUAL ⇒ 原样保留(只更新备注/审计)
+                    finalJudgement = p.DbItemJudgement;
+                    finalSource = p.DbJudgementSource;
+                }
+                else
+                {
+                    finalSource = IqcJudgementSource.Auto;
+                    if (p.EffectiveResultType == IqcResultType.Numeric)
+                    {
+                        // 样本判定清单:本次替换过则用内存值,否则回读该项目现存样本
+                        List<string?> judgements;
+                        if (p.Samples != null)
+                        {
+                            judgements = p.Samples.Select(x => (string?)x.Judgement).ToList();
+                        }
+                        else
+                        {
+                            var existing = await _db.Ado.SqlQueryAsync<string>(
+                                "SELECT judgement FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId ORDER BY sample_index",
+                                itemPars);
+                            judgements = existing.Select(x => (string?)x).ToList();
+                        }
+                        finalJudgement = IqcInspectionJudge.AggregateNumericItem(judgements);
+                    }
+                    else if (p.EffectiveResultType == IqcResultType.NonNumeric)
+                    {
+                        finalJudgement = IqcInspectionJudge.JudgeNonNumericItem(p.FinalQualifiedQty, p.FinalUnqualifiedQty);
+                    }
+                    else
+                    {
+                        // 类型未定 ⇒ 不允许有任何测量事实,判定必为空
+                        finalJudgement = null;
+                    }
+
+                    if (p.DbJudgementSource == IqcJudgementSource.Manual)
+                        manualInvalidated.Add(p.ItemId);
+                }
+
+                savedItems += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE ado_s5_iqc_insp_bill_item
+                    SET result_type      = @ResultType,
+                        qualified_qty    = @QualifiedQty,
+                        unqualified_qty  = @UnqualifiedQty,
+                        item_judgement   = @ItemJudgement,
+                        judgement_source = @JudgementSource,
+                        result_remark    = @ResultRemark,
+                        last_result_by   = @Uid,
+                        last_result_at   = @Now
+                    WHERE id = @ItemId AND inspection_bill_id = @BillId AND tenant_id = @TenantId
+                    """,
+                    new List<SugarParameter>
+                    {
+                        new("@ResultType", (object?)p.EffectiveResultType ?? DBNull.Value),
+                        new("@QualifiedQty", (object?)p.FinalQualifiedQty ?? DBNull.Value),
+                        new("@UnqualifiedQty", (object?)p.FinalUnqualifiedQty ?? DBNull.Value),
+                        new("@ItemJudgement", (object?)finalJudgement ?? DBNull.Value),
+                        new("@JudgementSource", (object?)finalSource ?? DBNull.Value),
+                        new("@ResultRemark", (object?)p.ResultRemark ?? DBNull.Value),
+                        new("@Uid", (object?)uid ?? DBNull.Value),
+                        new("@Now", now),
+                        new("@ItemId", p.ItemId),
+                        new("@BillId", input.BillId),
+                        new("@TenantId", tenantId),
+                    });
+            }
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("保存检验项目结果失败");
+
+        // ── ⑦ 回执:重读全量行 + 实时汇总 ──
+        var lines = await _reader.LoadLinesAsync(input.BillId, tenantId);
+        var touched = plans.Select(p => p.ItemId).ToHashSet();
+        return new IqcSaveDetailOutput
+        {
+            Ok = true,
+            SavedItems = savedItems,
+            SavedSamples = savedSamples,
+            DeletedSamples = deletedSamples,
+            ClearedByTypeChange = clearedByTypeChange,
+            ManualInvalidated = manualInvalidated,
+            LineSummary = IqcInspBillItemReader.Summarize(lines),
+            Items = lines.Where(l => touched.Contains(l.Id)).ToList(),
+        };
+    }
+
+    // ───────────────────────── 计划构建(纯校验,无 DB) ─────────────────────────
+
+    /// <summary>
+    /// 把入参归一成写入计划,并在此完成全部语义校验。任何违规直接抛,保证事务前失败。
+    /// </summary>
+    private static ItemPlan BuildPlan(IqcSaveDetailItemInput it, ItemCfgRow db)
+    {
+        // 录入类型
+        if (it.ResultType != null && !IqcResultType.IsValid(it.ResultType))
+            throw Oops.Oh($"检验明细 {it.ItemId} 的录入类型仅支持 NUMERIC / NON_NUMERIC");
+
+        var effective = it.ResultType ?? db.ResultType;
+        var typeChanged = it.ResultType != null && it.ResultType != db.ResultType;
+
+        var hasQty = it.QualifiedQty.HasValue || it.UnqualifiedQty.HasValue;
+        var hasSamples = it.Samples != null;
+
+        // 单一真源:类型与测量形态必须匹配
+        if (effective == IqcResultType.Numeric && hasQty)
+            throw Oops.Oh($"检验明细 {it.ItemId} 为数值型,测量真源是样本值,不接受合格/不合格数");
+        if (effective == IqcResultType.NonNumeric && hasSamples)
+            throw Oops.Oh($"检验明细 {it.ItemId} 为非数值型,测量真源是合格/不合格数,不接受样本值");
+        if (effective == null && (hasQty || hasSamples))
+            throw Oops.Oh($"检验明细 {it.ItemId} 尚未选择录入类型,不能录入测量结果");
+
+        // 数量合法性
+        if (it.QualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的合格数不能为负");
+        if (it.UnqualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的不合格数不能为负");
+
+        // 人工判定
+        if (it.ManualJudgement != null && !IqcInspectionJudge.IsValidManualJudgement(it.ManualJudgement))
+            throw Oops.Oh($"检验明细 {it.ItemId} 的人工判定仅允许 PASS / FAIL");
+
+        // 样本:序号 >0、项目内不重复、原文非空
+        List<SamplePlan>? samples = null;
+        if (hasSamples)
+        {
+            samples = new List<SamplePlan>(it.Samples!.Count);
+            var seen = new HashSet<int>();
+            foreach (var s in it.Samples!)
+            {
+                if (s.SampleIndex < 1)
+                    throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号必须大于 0");
+                if (!seen.Add(s.SampleIndex))
+                    throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号 {s.SampleIndex} 重复");
+
+                var raw = s.SampleValueRaw?.Trim();
+                if (string.IsNullOrEmpty(raw))
+                    throw Oops.Oh($"检验明细 {it.ItemId} 的样本 {s.SampleIndex} 未填录入值(如需删除请从数组中移除该样本)");
+
+                samples.Add(new SamplePlan
+                {
+                    SampleIndex = s.SampleIndex,
+                    Raw = raw,
+                    Numeric = IqcInspectionJudge.ParseNumeric(raw),
+                    // 判定只用快照冻结的上下限,绝不解析 inspection_standard 自由文本
+                    Judgement = IqcInspectionJudge.JudgeSampleNumeric(raw, db.LowerLimit, db.UpperLimit),
+                    Remark = s.Remark,
+                });
+            }
+            samples = samples.OrderBy(x => x.SampleIndex).ToList();
+        }
+
+        // 计数真源的最终值:切到非数值型、或本次显式给了数量 → 取本次值;否则沿用库中值
+        decimal? finalQualified;
+        decimal? finalUnqualified;
+        if (effective == IqcResultType.Numeric)
+        {
+            finalQualified = null;   // 强制:数值型不得携带计数
+            finalUnqualified = null;
+        }
+        else if (effective == IqcResultType.NonNumeric && (typeChanged || hasQty))
+        {
+            finalQualified = it.QualifiedQty;
+            finalUnqualified = it.UnqualifiedQty;
+        }
+        else if (effective == IqcResultType.NonNumeric)
+        {
+            finalQualified = db.QualifiedQty;
+            finalUnqualified = db.UnqualifiedQty;
+        }
+        else
+        {
+            finalQualified = null;
+            finalUnqualified = null;
+        }
+
+        // 测量事实是否变化 —— 决定旧 MANUAL 是否失效(§禁止 stale verdict)
+        var measurementChanged = typeChanged || hasSamples || hasQty;
+
+        return new ItemPlan
+        {
+            ItemId = it.ItemId,
+            EffectiveResultType = effective,
+            TypeChanged = typeChanged,
+            Samples = samples,
+            FinalQualifiedQty = finalQualified,
+            FinalUnqualifiedQty = finalUnqualified,
+            ResultRemark = it.ResultRemark,
+            ManualJudgement = it.ManualJudgement,
+            DbItemJudgement = db.ItemJudgement,
+            DbJudgementSource = db.JudgementSource,
+            // 未改测量事实且原本是人工结论 ⇒ 保留原判定(只更备注/审计),不被 AUTO 覆盖
+            KeepExistingVerdict = !measurementChanged && db.JudgementSource == IqcJudgementSource.Manual,
+        };
+    }
+
+    private sealed class ItemCfgRow
+    {
+        public long Id { get; set; }
+        public string? ResultType { get; set; }
+        public string? UpperLimit { get; set; }
+        public string? LowerLimit { get; set; }
+        public decimal? QualifiedQty { get; set; }
+        public decimal? UnqualifiedQty { get; set; }
+        public string? ItemJudgement { get; set; }
+        public string? JudgementSource { get; set; }
+    }
+
+    private sealed class ItemPlan
+    {
+        public long ItemId { get; set; }
+        public string? EffectiveResultType { get; set; }
+        public bool TypeChanged { get; set; }
+        public List<SamplePlan>? Samples { get; set; }
+        public decimal? FinalQualifiedQty { get; set; }
+        public decimal? FinalUnqualifiedQty { get; set; }
+        public string? ResultRemark { get; set; }
+        public string? ManualJudgement { get; set; }
+        public string? DbItemJudgement { get; set; }
+        public string? DbJudgementSource { get; set; }
+        public bool KeepExistingVerdict { get; set; }
+    }
+
+    private sealed class SamplePlan
+    {
+        public int SampleIndex { get; set; }
+        public string Raw { get; set; } = "";
+        public decimal? Numeric { get; set; }
+        public string Judgement { get; set; } = "";
+        public string? Remark { get; set; }
+    }
+}

+ 42 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcTaskEntryService.cs

@@ -270,6 +270,10 @@ public class IqcTaskEntryService : IDynamicApiController, ITransient
         // 真正的系统错误(DB 不可用 / SQL 异常)仍按异常向上抛,不吞。
         var spec = await _specResolver.ResolveAsync(tenantId, entry.MaterialCode, now);
 
+        // B2:本单冻结的检规明细条数。闭包内赋值,事务回滚时该返回值不会被使用(324 行直接抛)。
+        // 与 SpecStatus 是两个正交事实:SpecStatus=MATCHED 且 ItemCount=0 是合法状态(检规未维护明细)。
+        var itemCount = 0;
+
         var tran = await _db.Ado.UseTranAsync(async () =>
         {
             await _db.Ado.ExecuteCommandAsync(
@@ -320,6 +324,43 @@ public class IqcTaskEntryService : IDynamicApiController, ITransient
                 new SugarParameter("@Jygfid", spec.IsMatched && spec.SpecId.HasValue
                     ? spec.SpecId.Value.ToString()
                     : (object)DBNull.Value));
+
+            // ── B2 检规明细快照(与 Header 同一事务)────────────────────────────────
+            // 把"生成时点"的检规明细只读冻结进 ado_s5_iqc_insp_bill_item,此后详情/审核
+            // 一律读该快照,绝不回查 qms_jygfzb(否则 S0 改检规会篡改历史检验单)。
+            //
+            // 【租户边界】源侧 z.tenant_id=@TenantId 过滤;写入的 tenant_id 取调用方解析值,
+            //   **绝不透传 z.tenant_id**,杜绝跨租户检规被快照进本租户单据。
+            // 【只读边界】qms_jygfzb 仅 SELECT,本方法不对任何 qms_* 做写入。
+            // 【0 行合法】无快照对象时写 0 行且不抛错,Header 照常提交:
+            //   · IsMatched=false(NO_SPEC / NO_EFFECTIVE_SPEC / AMBIGUOUS)→ 不执行本 INSERT
+            //   · IsMatched=true 但该检规 0 明细 → INSERT…SELECT 命中 0 行
+            //   刻意不照抄 S6 的 "lineCount<=0 → throw"(S6ProcessInspectionService.cs:157-158):
+            //   S5 的报检分录是 165 贴源既成事实,Ai-DOP 无权因检规配置不全拒绝建检验单
+            //   (依据 S5IqcSpecResolver.cs:76-79 的刻意差异)。
+            // 【result_type 不推导】Result 组 8 列一律不写、保持 NULL;录入类型由 N1 首次
+            //   录入时选定(qms_jygfzb 无 lrlx 列,不存在可冻结的来源)。
+            if (spec.IsMatched && spec.SpecId.HasValue)
+            {
+                itemCount = await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO ado_s5_iqc_insp_bill_item (
+                        tenant_id, inspection_bill_id, seq, source_spec_id, source_spec_item_id,
+                        inspection_item, inspection_standard, method_tool, image_category,
+                        sampling_scheme, spec_remark, upper_limit, lower_limit
+                    )
+                    SELECT
+                        @TenantId, @BillId, z.xh, @SpecId, z.id,
+                        z.jyxm, z.jybz, z.jygjjyff, z.txfl,
+                        z.cyfa, z.zs, z.sx, z.xx
+                    FROM qms_jygfzb z
+                    WHERE z.tenant_id = @TenantId AND z.glid = @SpecId
+                    ORDER BY z.xh, z.id
+                    """,
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@BillId", billId),
+                    new SugarParameter("@SpecId", spec.SpecId.Value));
+            }
         });
         if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("生成检验单失败");
 
@@ -331,6 +372,7 @@ public class IqcTaskEntryService : IDynamicApiController, ITransient
             SpecNo = spec.IsMatched ? spec.SpecNo : null,
             SpecWarning = spec.Warning,
             SpecCandidateCount = spec.CandidateCount,
+            ItemCount = itemCount,
         };
     }
 

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

@@ -0,0 +1,613 @@
+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);
+
+        // Stage-2 必须在 Stage-1 KPI 之后跑:它直接消费 dwd_order_review_kpi.review_end_time
+        // 作为 Start,而不是自己再实现一遍 Stage-1 的配对逻辑。
+        result.ProductDesignKpiRows = await BuildProductDesignKpiAsync(tenantId, batchId, now, cancellationToken);
+
+        _logger.LogInformation(
+            "S1 动作事件链路完成 tenant={TenantId} batch={BatchId} stg={Stg} repull={Repull} std={Std} kpi={Kpi} pdKpi={PdKpi}",
+            tenantId, batchId, result.StageRows, result.StageRepulledRunning,
+            result.StandardRows, result.KpiRows, result.ProductDesignKpiRows);
+
+        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')
+            """;
+
+    // ══════════════════════════════════════════════════════════════════════
+    // Stage-2 · 产品设计 KPI(dwd_product_design_kpi)
+    // ══════════════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// Stage-2「产品设计」KPI。Grain = <c>tenant_id + sales_order_id</c>。
+    ///
+    /// <para><b>Start</b> 直接取 <c>dwd_order_review_kpi.review_end_time</c> —— Stage-1 已有正式
+    /// DWD Authority,这里只消费,<b>不重新实现 Stage-1 的配对逻辑</b>。</para>
+    ///
+    /// <para><b>End</b> 取该订单 <c>S2_DAILY_PLAN_RELEASE</c> 的 <b>FIRST SUCCESS</b>
+    /// (<c>ORDER BY action_end_time, action_run_id</c>)的 <c>action_end_time</c>。
+    /// 将来同一订单再次下达也<b>不会改写</b>已成型的 Stage-2 End —— 首个里程碑一旦产生即恒定,
+    /// 故本 transform 是 deterministic / idempotent。</para>
+    ///
+    /// <para><b>END_UNKNOWN 是本表存在的核心理由</b>:1.0.532 之前没有释放事件,历史上已下达的订单
+    /// 只能证明「已下达」、无法证明「何时下达」。这类订单必须与「确实还没下达」的 IN_PROGRESS
+    /// 区分开,否则会把历史数据缺口误判成业务未完成。区分依据是中台里是否存在该订单
+    /// <c>status='Y'</c> 的日计划行 —— <b>只取这个布尔事实,绝不取它的时间</b>。</para>
+    ///
+    /// <para><b>MDP ONLY</b>:全部取数来自 <c>dwd_order_review_kpi</c> / <c>mdp_std_action_event</c> /
+    /// <c>mdp_std_operation_schedule</c> / <c>mdp_std_work_order_schedule</c> / <c>mdp_std_so</c>,
+    /// 不 JOIN 任何源业务表。</para>
+    ///
+    /// <para><b>订单全集以 <c>mdp_std_so</c> 为准</b>,而不是由释放事件驱动 ——
+    /// 否则「尚未开始评审」的订单会从档案里整个消失。</para>
+    /// </summary>
+    public async Task<int> BuildProductDesignKpiAsync(
+        long tenantId, string batchId, DateTime now, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) throw new InvalidOperationException("BuildProductDesignKpiAsync 需要有效 tenantId。");
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var ps = new SugarParameter[]
+        {
+            new("@TenantId", tenantId),
+            new("@BatchId", batchId),
+            new("@Now", now)
+        };
+
+        // 逻辑计数:本轮参与 Stage-2 KPI transform 的 distinct 销售订单数(非 DB affected rows)
+        var logicalRows = await _db.Ado.GetIntAsync($"""
+            SELECT COUNT(*) FROM ({ProductDesignOrderScope}) o
+            """, ps);
+
+        const string sql = $"""
+            INSERT INTO `dwd_product_design_kpi`
+              (`tenant_id`, `sales_order_id`, `sales_order_no`, `review_end_time`, `release_action_run_id`,
+               `product_design_start_time`, `product_design_end_time`, `actual_hours`, `calculation_status`,
+               `release_success_count`, `release_failed_count`, `has_released_schedule`,
+               `calc_batch_id`, `calc_time`)
+            WITH orders AS ({ProductDesignOrderScope}),
+            -- 该订单的释放事件全集(用于计数;SUCCESS/FAILED 都留在事件层)
+            rel_all AS (
+              SELECT `tenant_id`, `biz_id`,
+                     SUM(`action_status` = 'SUCCESS') AS succ_cnt,
+                     SUM(`action_status` = 'FAILED')  AS fail_cnt
+                FROM `mdp_std_action_event`
+               WHERE `tenant_id` = @TenantId
+                 AND `action_code` = 'S2_DAILY_PLAN_RELEASE'
+                 AND `biz_type`    = 'crm_seorder'
+                 AND `biz_id` IS NOT NULL
+               GROUP BY `tenant_id`, `biz_id`
+            ),
+            -- FIRST SUCCESS:Stage-2 End 永远取首个成功释放,后续释放不改写历史
+            rel_rank AS (
+              SELECT `tenant_id`, `biz_id`, `action_run_id`, `action_end_time`,
+                     ROW_NUMBER() OVER (PARTITION BY `tenant_id`, `biz_id`
+                                        ORDER BY `action_end_time` ASC, `action_run_id` ASC) AS rn
+                FROM `mdp_std_action_event`
+               WHERE `tenant_id` = @TenantId
+                 AND `action_code` = 'S2_DAILY_PLAN_RELEASE'
+                 AND `biz_type`    = 'crm_seorder'
+                 AND `biz_id` IS NOT NULL
+                 AND `action_status` = 'SUCCESS'
+                 AND `action_end_time` IS NOT NULL
+            ),
+            rel1 AS (SELECT * FROM rel_rank WHERE rn = 1),
+            -- 历史「已下达」状态佐证:MDP-only ID 桥
+            --   operation_schedule → work_order_schedule.sales_order_entry_id → std_so.order_entry_id → order_id
+            -- 只产出布尔,不取任何时间(该层的 update_time 会被覆盖,不是下达时刻)
+            rel_sched AS (
+              SELECT DISTINCT so.`tenant_id`, so.`order_id`
+                FROM `mdp_std_operation_schedule` op
+                JOIN `mdp_std_work_order_schedule` wo
+                  ON wo.`tenant_id` = op.`tenant_id` AND wo.`work_order` = op.`work_order`
+                JOIN `mdp_std_so` so
+                  ON so.`tenant_id` = wo.`tenant_id` AND so.`order_entry_id` = wo.`sales_order_entry_id`
+               WHERE op.`tenant_id` = @TenantId
+                 AND op.`status` = 'Y'
+                 AND wo.`sales_order_entry_id` IS NOT NULL
+                 AND so.`order_id` IS NOT NULL
+            )
+            SELECT
+              o.`tenant_id`,
+              o.`order_id`,
+              o.order_no,
+              k.`review_end_time`,
+              r.`action_run_id`,
+              k.`review_end_time` AS product_design_start_time,
+              r.`action_end_time` AS product_design_end_time,
+              CASE WHEN k.`review_end_time` IS NOT NULL
+                    AND r.`action_end_time` IS NOT NULL
+                    AND r.`action_end_time` > k.`review_end_time`
+                   THEN ROUND(TIMESTAMPDIFF(SECOND, k.`review_end_time`, r.`action_end_time`) / 3600.0, 2)
+              END AS actual_hours,
+              CASE
+                -- 无 Start 无 End → 还没走到这一步
+                WHEN k.`review_end_time` IS NULL AND r.`action_end_time` IS NULL THEN 'NOT_STARTED'
+                -- 有 End 却没有 Start → 数据异常,不得倒推 Start
+                WHEN k.`review_end_time` IS NULL                                 THEN 'INVALID_NO_START'
+                -- End <= Start → 时序异常,绝不 abs()/swap() 硬算正数
+                WHEN r.`action_end_time` IS NOT NULL
+                     AND r.`action_end_time` <= k.`review_end_time`              THEN 'INVALID_TIME_SEQUENCE'
+                WHEN r.`action_end_time` IS NOT NULL                             THEN 'OK'
+                -- 无释放事件但中台证明已下达 → 历史数据,时间 Authority 缺失
+                WHEN s.`order_id` IS NOT NULL                                    THEN 'END_UNKNOWN'
+                ELSE 'IN_PROGRESS'
+              END AS calculation_status,
+              IFNULL(ra.succ_cnt, 0),
+              IFNULL(ra.fail_cnt, 0),
+              CASE WHEN s.`order_id` IS NOT NULL THEN 1 ELSE 0 END,
+              @BatchId, @Now
+            FROM orders o
+            LEFT JOIN `dwd_order_review_kpi` k
+                   ON k.`tenant_id` = o.`tenant_id` AND k.`sales_order_id` = o.`order_id`
+            LEFT JOIN rel1 r    ON r.`tenant_id`  = o.`tenant_id` AND r.`biz_id`   = o.`order_id`
+            LEFT JOIN rel_all ra ON ra.`tenant_id` = o.`tenant_id` AND ra.`biz_id`  = o.`order_id`
+            LEFT JOIN rel_sched s ON s.`tenant_id` = o.`tenant_id` AND s.`order_id` = o.`order_id`
+            ON DUPLICATE KEY UPDATE
+              `sales_order_no`            = VALUES(`sales_order_no`),
+              `review_end_time`           = VALUES(`review_end_time`),
+              `release_action_run_id`     = VALUES(`release_action_run_id`),
+              `product_design_start_time` = VALUES(`product_design_start_time`),
+              `product_design_end_time`   = VALUES(`product_design_end_time`),
+              `actual_hours`              = VALUES(`actual_hours`),
+              `calculation_status`        = VALUES(`calculation_status`),
+              `release_success_count`     = VALUES(`release_success_count`),
+              `release_failed_count`      = VALUES(`release_failed_count`),
+              `has_released_schedule`     = VALUES(`has_released_schedule`),
+              `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>
+    /// Stage-2 KPI 的订单全集:以 <c>mdp_std_so</c> 的订单头为准(一订单一行)。
+    /// <para>被计数查询与 CTE <c>orders</c> 共用。刻意<b>不</b>由释放事件驱动 ——
+    /// 那样「尚未开始评审」的订单会从档案里消失。</para>
+    /// </summary>
+    private const string ProductDesignOrderScope = """
+              SELECT `tenant_id`, `order_id`, MAX(`order_no`) AS order_no
+                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><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; }
+
+    /// <summary>本轮参与 Stage-2 产品设计 KPI transform 的 distinct 销售订单数。</summary>
+    public int ProductDesignKpiRows { get; set; }
+}

+ 147 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs

@@ -10,13 +10,32 @@ namespace Admin.NET.Plugin.AiDOP.Production;
 [NonUnify]
 public class ExecutableDailyPlanService : IDynamicApiController, ITransient
 {
+    /// <summary>
+    /// S8 Stage-2「产品设计」的 End 事件码。
+    ///
+    /// <para>语义:某张真实销售订单<b>首次</b>有至少一条日计划由「未下达」成功变为
+    /// <c>Status='Y'</c>,即该订单首次具备可执行生产日计划。</para>
+    ///
+    /// <para>Stage-2 End 取本事件 <b>FIRST SUCCESS 的 end_time</b>(不是 start_time)——
+    /// end_time 写在业务状态提交成功之后,与 Stage-1 的 <c>S1_DELIVERY_CONFIRM</c> 同口径。</para>
+    ///
+    /// <para>⚠️ 动作码前缀 <c>S2_</c> 只是本系统的动作命名,<b>不代表 S8 五阶段归属</b>;
+    /// 本事件服务于 S8 的 PRODUCT_DESIGN 阶段。</para>
+    /// </summary>
+    public const string ActionDailyPlanRelease = "S2_DAILY_PLAN_RELEASE";
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly AidopActionRunLogWriter _runLog;
 
-    public ExecutableDailyPlanService(ISqlSugarClient db, UserManager userManager)
+    public ExecutableDailyPlanService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        AidopActionRunLogWriter runLog)
     {
         _db = db;
         _userManager = userManager;
+        _runLog = runLog;
     }
 
     /// <summary>分页列表 GET /api/Production/daily-plan/list</summary>
@@ -136,28 +155,142 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
         var account = _userManager.Account ?? "system";
         var now = DateTime.Now;
         var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        if (tenantId <= 0)
+            throw Oops.Oh("无法确定当前租户,日计划下达已拒绝");   // fail closed,绝不按默认租户写业务状态
         const string releasedFlag = "Y";
 
-        var n = await _db.Ado.ExecuteCommandAsync(
+        var idList = string.Join(",", ids);
+
+        // ── 1. 只挑【本次真正会发生状态跃迁】的计划行,并当场解析其销售订单身份 ──
+        // 为什么在业务写路径里直读源表:事件要记录的就是"源系统刚刚发生了什么",
+        // 让 release 事务反向依赖 mdp_std_so 才能完成会把方向搞反(Source → MDP → S8,
+        // 不是 Source → MDP → 再决定能不能 release)。S8 的「只读中台」约束管的是消费侧。
+        //
+        // ID 桥(全程 tenant 内,禁止 SalesJob 文本归因):
+        //   PeriodSequenceDet.WorkOrds → WorkOrdMaster.WorkOrd
+        //     → WorkOrdMaster.BusinessID (= crm_seorderentry.Id)
+        //       → crm_seorderentry.seorder_id (= crm_seorder.Id)
+        var transitions = await _db.Ado.SqlQueryAsync<PlanReleaseTransitionRow>(
             $"""
-            UPDATE PeriodSequenceDet
-            SET `Status` = @Flag,
-                UpdateUser = @UpdateUser,
-                UpdateTime = @UpdateTime
-            WHERE RecID IN ({string.Join(",", ids)})
-              AND `Domain` = @Domain
-              AND tenant_id = @TenantId
+            SELECT p.RecID          AS PlanRecId,
+                   p.WorkOrds       AS WorkOrd,
+                   o.Id             AS SalesOrderId,
+                   o.bill_no        AS SalesOrderNo
+              FROM PeriodSequenceDet p
+              JOIN WorkOrdMaster w      ON w.tenant_id = p.tenant_id AND w.WorkOrd = p.WorkOrds
+              JOIN crm_seorderentry e   ON e.tenant_id = p.tenant_id AND e.Id = w.BusinessID
+              JOIN crm_seorder o        ON o.tenant_id = p.tenant_id AND o.Id = e.seorder_id
+             WHERE p.tenant_id = @TenantId
+               AND p.`Domain`  = @Domain
+               AND p.RecID IN ({idList})
+               AND IFNULL(p.`Status`, '') <> @Flag
             """,
             new List<SugarParameter>
             {
-                new("@Flag", releasedFlag),
-                new("@UpdateUser", account),
-                new("@UpdateTime", now),
+                new("@TenantId", tenantId),
                 new("@Domain", domain),
-                new("@TenantId", tenantId)
+                new("@Flag", releasedFlag)
             });
 
-        return new { message = "下达成功", affected = n };
+        // ── 2. 按销售订单去重:一次 release 内,同一订单最多一条里程碑事件 ──
+        // 不是一条计划行一条事件 —— Stage KPI 的 Grain 是订单,不是工序行。
+        var byOrder = (transitions ?? [])
+            .GroupBy(t => t.SalesOrderId)
+            .Select(g => new
+            {
+                SalesOrderId = g.Key,
+                SalesOrderNo = g.First().SalesOrderNo,
+                PlanRecIds = g.Select(x => x.PlanRecId).Distinct().ToList(),
+                WorkOrders = g.Select(x => x.WorkOrd).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList()
+            })
+            .ToList();
+
+        // ── 3. 无真实跃迁 → 不写任何里程碑 ──
+        // 重复点击下达(目标行已是 'Y')在这里被拦住:普通重复 UPDATE 不是业务里程碑。
+        if (byOrder.Count == 0)
+        {
+            var repeated = await _db.Ado.ExecuteCommandAsync(
+                $"""
+                UPDATE PeriodSequenceDet
+                SET UpdateUser = @UpdateUser, UpdateTime = @UpdateTime
+                WHERE RecID IN ({idList}) AND `Domain` = @Domain AND tenant_id = @TenantId
+                  AND IFNULL(`Status`, '') = @Flag
+                """,
+                new List<SugarParameter>
+                {
+                    new("@UpdateUser", account),
+                    new("@UpdateTime", now),
+                    new("@Domain", domain),
+                    new("@TenantId", tenantId),
+                    new("@Flag", releasedFlag)
+                });
+            return new { message = repeated > 0 ? "所选计划已处于下达状态,未产生新的下达里程碑" : "没有可下达的计划行",
+                         affected = 0, milestoneCount = 0 };
+        }
+
+        // ── 4. 先开事件(RUNNING),再做业务写;成败与事务结果严格一致 ──
+        var runLogIds = new Dictionary<long, long>();
+        foreach (var ord in byOrder)
+            runLogIds[ord.SalesOrderId] = await _runLog.StartAsync(
+                ActionDailyPlanRelease, tenantId, "crm_seorder", ord.SalesOrderId, ord.SalesOrderNo);
+
+        int n;
+        try
+        {
+            // 只更新"本次确实要跃迁"的那些行,不把已下达行卷进来
+            var transitionIds = string.Join(",", byOrder.SelectMany(o => o.PlanRecIds).Distinct());
+            n = await _db.Ado.ExecuteCommandAsync(
+                $"""
+                UPDATE PeriodSequenceDet
+                SET `Status` = @Flag,
+                    UpdateUser = @UpdateUser,
+                    UpdateTime = @UpdateTime
+                WHERE RecID IN ({transitionIds})
+                  AND `Domain` = @Domain
+                  AND tenant_id = @TenantId
+                  AND IFNULL(`Status`, '') <> @Flag
+                """,
+                new List<SugarParameter>
+                {
+                    new("@Flag", releasedFlag),
+                    new("@UpdateUser", account),
+                    new("@UpdateTime", now),
+                    new("@Domain", domain),
+                    new("@TenantId", tenantId)
+                });
+        }
+        catch (Exception ex)
+        {
+            // 业务写失败 → 每张订单的事件都必须落 FAILED,绝不允许留下 SUCCESS 里程碑
+            foreach (var ord in byOrder)
+                await _runLog.FailedAsync(runLogIds[ord.SalesOrderId], ex.Message,
+                    new { salesOrderId = ord.SalesOrderId, salesOrderNo = ord.SalesOrderNo });
+            throw;
+        }
+
+        // ── 5. 业务写成功后才 SUCCESS —— end_time 即 Stage-2 End Authority ──
+        foreach (var ord in byOrder)
+            await _runLog.SuccessAsync(runLogIds[ord.SalesOrderId],
+                $"日计划下达:订单 {ord.SalesOrderNo},计划行 {ord.PlanRecIds.Count} 条,工单 {ord.WorkOrders.Count} 个",
+                new
+                {
+                    salesOrderId = ord.SalesOrderId,
+                    salesOrderNo = ord.SalesOrderNo,
+                    releasedPlanCount = ord.PlanRecIds.Count,
+                    planRecIds = ord.PlanRecIds,
+                    workOrderCount = ord.WorkOrders.Count
+                });
+
+        return new { message = "下达成功", affected = n, milestoneCount = byOrder.Count };
+    }
+
+    /// <summary>日计划行 → 销售订单的归因投影(仅用于 release 事件,走 ID 桥不走单号文本)。</summary>
+    private sealed class PlanReleaseTransitionRow
+    {
+        public long PlanRecId { get; set; }
+        public string? WorkOrd { get; set; }
+        public long SalesOrderId { get; set; }
+        public string? SalesOrderNo { get; set; }
     }
 
     /// <summary>工单状态 PATCH POST /api/Production/daily-plan/patch-wo-status</summary>

+ 20 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -264,7 +264,7 @@ public class S2MdpSyncTransformService : ITransient
         yield return Cmd(
             """
             INSERT INTO mdp_std_work_order_schedule
-            (tenant_id, factory_id, source_system, work_order, sales_order_no, item_code, item_name, site_code, status,
+            (tenant_id, factory_id, source_system, work_order, sales_order_no, sales_order_entry_id, item_code, item_name, site_code, status,
              priority, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, prod_line,
              specification, lot_serial, drawing_no, project, work_order_type, labor_variance,
              source_biz_key, sync_batch_id, sync_time)
@@ -273,6 +273,12 @@ public class S2MdpSyncTransformService : ITransient
                    'AIDOP',
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.SalesJob')),
+                   -- 订单行 ID 桥:WorkOrdMaster.BusinessID,>0 时恒等于 crm_seorderentry.Id
+                   -- (全库实测 28/28 命中同租户订单行、0 孤儿)。源值 0 表示「无关联订单行」,
+                   -- 必须归一为 NULL —— 0 不是合法订单行 Id,落 0 会在桥上造出假关联。
+                   -- sales_order_no(=SalesJob) 只是文本,禁止用它做归因。
+                   NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) REGEXP '^[0-9]+$'
+                               THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) AS SIGNED) END, 0),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemName')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Site')),
@@ -303,7 +309,8 @@ public class S2MdpSyncTransformService : ITransient
                 AND im.ItemNum = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum'))
             WHERE s.source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')), '') <> ''
             ON DUPLICATE KEY UPDATE
-                sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
+                sales_order_no=VALUES(sales_order_no), sales_order_entry_id=VALUES(sales_order_entry_id),
+                item_code=VALUES(item_code), item_name=VALUES(item_name),
                 site_code=VALUES(site_code), status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
                 qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
                 due_date=VALUES(due_date), release_date=VALUES(release_date), prod_line=VALUES(prod_line),
@@ -316,7 +323,7 @@ public class S2MdpSyncTransformService : ITransient
             """
             INSERT INTO mdp_std_operation_schedule
             (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
-             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee,
+             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee, status,
              source_biz_key, sync_batch_id, sync_time)
             SELECT tenant_id,
                    CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
@@ -334,6 +341,11 @@ public class S2MdpSyncTransformService : ITransient
                    CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) AS DECIMAL(18,6)) ELSE 0 END,
                    CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) AS DECIMAL(18,6)) END,
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Employee')),
+                   -- 日计划下达状态:'Y'=已下达(可执行),空=未下达。原样投影 PeriodSequenceDet.Status。
+                   -- 它只是【状态佐证】—— Stage-2 End 的时间 Authority 一律取
+                   -- S2_DAILY_PLAN_RELEASE SUCCESS 的 end_time,绝不能用 update_time 当下达时间
+                   -- (update_time 会被后续任意一次 UPDATE 覆盖)。
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Status')),
                    source_biz_key, @BatchId, @Now
             FROM mdp_stg_schedule
             WHERE source_table='PeriodSequenceDet' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')), '') <> ''
@@ -341,9 +353,14 @@ public class S2MdpSyncTransformService : ITransient
                 work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
                 plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
                 ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
+                status=VALUES(status),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
             """, batchId, now);
 
+        // ⚠️ 同表的另一个来源:ScheduleResultOpMaster —— 它没有「日计划下达」这个概念。
+        //    因此既不投影 status,ODKU 里也【刻意不列】status,
+        //    否则会把 PeriodSequenceDet 分支刚投影进来的下达状态覆盖成 NULL。
+        //    (两来源 source_biz_key 格式不同,uk=(tenant_id,source_biz_key) 实测不碰撞;此为纵深防御。)
         yield return Cmd(
             """
             INSERT INTO mdp_std_operation_schedule

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

@@ -540,6 +540,9 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         yield return new SysMenu { Id = iqcBase + 3, Pid = iqcDirId, Title = "来料检验申请列表", Path = "/aidop/s5/iqc/application-list", Name = "aidopS5IqcApplicationList", Component = "/aidop/s5/iqc/iqcApplicationList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 5, Remark = "S5 来料检验申请列表(只读列表)" };
         yield return new SysMenu { Id = iqcBase + 1, Pid = iqcDirId, Title = "来料检验任务列表", Path = "/aidop/s5/iqc/task-list", Name = "aidopS5IqcTaskList", Component = "/aidop/s5/iqc/iqcTaskList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 10, Remark = "S5 来料检验任务列表(只读列表)" };
         yield return new SysMenu { Id = iqcBase + 2, Pid = iqcDirId, Title = "来料检验结果列表", Path = "/aidop/s5/iqc/result-list", Name = "aidopS5IqcResultList", Component = "/aidop/s5/iqc/iqcResultList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 20, Remark = "S5 来料检验结果列表(只读列表)" };
+        // 隐藏详情路由:不出侧栏,仅参与动态路由注册。IsKeepAlive 必须显式 false(实体默认 true)——
+        // 详情页为强状态页面(N1 可编辑 → N2 只读 → 退回 → N1 可编辑),缓存会残留旧 canEdit。
+        yield return new SysMenu { Id = iqcBase + 4, Pid = iqcDirId, Title = "IQC检验单详情", Path = "/aidop/s5/iqc/detail/:id", Name = "aidopS5IqcInspBillDetail", Component = "/aidop/s5/iqc/iqcInspBillDetail", Icon = "ele-Document", Type = MenuTypeEnum.Menu, IsHide = true, IsKeepAlive = false, CreateTime = ct, OrderNo = 45, Remark = "S5 IQC检验单详情统一页(隐藏路由,来料检验任务列表两个 Tab 与生成后自动进入)" };
 
         // 仓储管理 (1322000000016) 下 5 个三级
         const long warehouseDirId = 1322000000016L;
@@ -1190,6 +1193,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
+        };
+    }
+}

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

@@ -0,0 +1,316 @@
+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 阶段事实
+/// → LEFT JOIN <c>dwd_product_design_kpi</c> → PRODUCT_DESIGN 阶段事实
+/// → 固定五阶段骨架 → 前端档案页。</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,
+                   p.`product_design_start_time` AS DesignStartTime,
+                   p.`product_design_end_time`   AS DesignEndTime,
+                   p.`actual_hours`              AS DesignActualHours,
+                   p.`calculation_status`        AS DesignCalculationStatus
+              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`
+              LEFT JOIN `dwd_product_design_kpi` p
+                     ON p.`tenant_id` = h.`tenant_id`
+                    AND p.`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,
+                   p.`product_design_start_time` AS DesignStartTime,
+                   p.`product_design_end_time`   AS DesignEndTime,
+                   p.`actual_hours`              AS DesignActualHours,
+                   p.`calculation_status`        AS DesignCalculationStatus
+              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`
+              LEFT JOIN `dwd_product_design_kpi` p
+                     ON p.`tenant_id` = h.`tenant_id`
+                    AND p.`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;
+            var isDesign = code == OrderFlowConstants.PRODUCT_DESIGN;
+
+            list.Add(new AdoS8OrderFlowStageDto
+            {
+                OrderFlowCode = code,
+                OrderFlowName = OrderFlowConstants.ResolveName(code),
+                SortNo = i + 1,
+                OwnerDept = null,
+
+                // 目前只有 ORDER_REVIEW 与 PRODUCT_DESIGN 有真实事实;
+                // Stage 3~5(材料采购 / 本体生产 / 总装发货)一律 null —— 即便这些域
+                // 已经有真实业务数据(工单 / MRP / 采购 / 排产),本批也不顺手点亮。
+                ActualStartAt = isReview ? r.ReviewStartTime : isDesign ? r.DesignStartTime : null,
+                ActualEndAt = isReview ? r.ReviewEndTime : isDesign ? r.DesignEndTime : null,
+                ActualHours = isReview ? r.ActualHours : isDesign ? r.DesignActualHours : null,
+                CalculationStatus = isReview ? r.CalculationStatus : isDesign ? r.DesignCalculationStatus : 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; }
+        public DateTime? DesignStartTime { get; set; }
+        public DateTime? DesignEndTime { get; set; }
+        public decimal? DesignActualHours { get; set; }
+        public string? DesignCalculationStatus { get; set; }
+    }
+}

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

@@ -159,6 +159,41 @@ public class FlowTaskService : IDynamicApiController, ITransient
 
     // ── 操作 ──
 
+    /// <summary>
+    /// 只能经专用业务入口审批的业务类型 → 对应的引导文案。
+    ///
+    /// 背景:这些业务在专用服务里额外建立了通用审批链没有的 authority
+    /// (租户解析 → 业务单归属校验 → 当前节点守卫 → UseTranAsync 事务边界),
+    /// 走通用 flowTask/approve|reject 会把这四层全部绕过(引擎侧 GetPendingTask
+    /// 只校验「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户)。
+    /// 仅隐藏前端按钮不足以闭环——手工 HTTP 仍可直达,故在网关层 fail-closed。
+    ///
+    /// 这里刻意硬编码 BizType 字符串:ApprovalFlow 插件不引用 AiDOP 插件
+    /// (依赖方向为 AiDOP → ApprovalFlow,反向会成环),无法引用业务侧常量。
+    /// </summary>
+    private static readonly Dictionary<string, string> DedicatedEntryOnlyBizTypes = new()
+    {
+        ["S5_IQC_INSPBILL"] = "来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
+    };
+
+    /// <summary>
+    /// 通用审批动作前置守卫:按 taskId 从库中反查权威 BizType,命中名单即拒绝。
+    ///
+    /// 只做「拒绝」不做「放行授权」,故不引入新的租户判定
+    /// (ApprovalFlowTask / ApprovalFlowInstance 无 TenantId 列,租户边界仍由
+    /// 各业务专用入口的业务表 tenant 谓词负责)。
+    /// 任务/实例不存在时不在此抛错,交由引擎沿用既有错误语义。
+    /// </summary>
+    private async Task EnsureDedicatedEntryOnlyAsync(long taskId)
+    {
+        var task = await _taskRep.GetByIdAsync(taskId);
+        if (task == null) return;
+        var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
+        if (instance?.BizType == null) return;
+        if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint))
+            throw Oops.Oh(hint);
+    }
+
     /// <summary>
     /// 同意
     /// </summary>
@@ -167,6 +202,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("同意")]
     public async Task Approve(TaskActionInput input)
     {
+        await EnsureDedicatedEntryOnlyAsync(input.TaskId);
         await _engine.Approve(input.TaskId, input.Comment);
     }
 
@@ -178,6 +214,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("拒绝")]
     public async Task Reject(TaskActionInput input)
     {
+        await EnsureDedicatedEntryOnlyAsync(input.TaskId);
         await _engine.Reject(input.TaskId, input.Comment);
     }
 
@@ -302,6 +339,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
         {
             try
             {
+                await EnsureDedicatedEntryOnlyAsync(taskId);
                 await _engine.Approve(taskId, input.Comment);
                 result.SuccessCount++;
             }
@@ -327,6 +365,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
         {
             try
             {
+                await EnsureDedicatedEntryOnlyAsync(taskId);
                 await _engine.Reject(taskId, input.Comment);
                 result.SuccessCount++;
             }