Pengxy 2 dni temu
rodzic
commit
3bf1c88ec1
60 zmienionych plików z 7614 dodań i 953 usunięć
  1. 1 1
      Web/package.json
  2. 45 19
      Web/src/views/aidop/api/kanbanData.ts
  3. 0 176
      Web/src/views/aidop/kanban/data/homeModulesSync.ts
  4. 9 13
      Web/src/views/aidop/kanban/factualDisplay.spec.ts
  5. 3 7
      Web/src/views/aidop/kanban/s8.vue
  6. 48 1
      Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
  7. 24 2
      Web/src/views/dashboard/components/HomeCardGridView.vue
  8. 23 2
      Web/src/views/dashboard/components/HomeCardTableView.vue
  9. 23 1
      Web/src/views/dashboard/components/HomeCardTilesView.vue
  10. 2 65
      Web/src/views/dashboard/components/HomeModuleKpiCard.vue
  11. 87 0
      Web/src/views/dashboard/components/homeKpiPresentation.spec.ts
  12. 162 0
      Web/src/views/dashboard/components/homeKpiPresentation.ts
  13. 0 180
      Web/src/views/dashboard/data/homeModulesSync.ts
  14. 0 109
      Web/src/views/dashboard/data/s2Kpis.ts
  15. 104 0
      Web/src/views/dashboard/home.s8-empty.spec.ts
  16. 113 28
      Web/src/views/dashboard/home.vue
  17. 1295 0
      doc/plan/S8九宫格异常权威数据与模块灯态根治执行任务书.md
  18. 659 0
      doc/plan/九宫格S5-S9无数据根因修复执行任务书.md
  19. 1222 0
      doc/plan/九宫格最后真实值与数据新鲜度根治执行任务书.md
  20. 905 0
      doc/plan/系统集成/Ai-DOP第三方对接模拟器初步执行任务书.md
  21. 700 0
      doc/plan/系统集成/Ai-DOP第三方对接模拟器建设方案.md
  22. 7 0
      doc/plan/系统集成/README.md
  23. 2 25
      server/Admin.NET.Application/Configuration/Database.json
  24. 16 4
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  25. 84 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.sql
  26. 26 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.verify.sql
  27. 31 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.sql
  28. 14 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.verify.sql
  29. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/EmployeeEmploymentStatusMappingTests.cs
  30. 105 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/GridAndInboundGuardTests.cs
  31. 211 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/HomeGridLastKnownValueContractTests.cs
  32. 148 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpSourcePasswordResolverTests.cs
  33. 92 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryInboundLockGuardTests.cs
  34. 126 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryMaterializationRetryTests.cs
  35. 23 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryInboundLockGuardTests.cs
  36. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryReconScopeTests.cs
  37. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryTenantIsolationIntegrationTests.cs
  38. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StdInventoryTenantIsolationTests.cs
  39. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StockQueryUiAndPaginationTests.cs
  40. 43 21
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs
  41. 194 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8HomeAlertQueryServiceTests.cs
  42. 123 23
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs
  43. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S4.cs
  44. 51 114
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  45. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdmMirrorUpsertService.cs
  46. 0 87
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConnectionImportService.cs
  47. 95 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourcePasswordResolver.cs
  48. 82 14
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs
  49. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NativeNeutralProjectionService.cs
  50. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/T8BaseInboundMdpSyncService.cs
  51. 53 22
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/ShipTransNeutralProjection.cs
  52. 3 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs
  53. 22 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs
  54. 52 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs
  55. 50 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryInboundLockGuard.cs
  56. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMaterializationRetry.cs
  57. 59 23
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs
  58. 290 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs
  59. 98 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/HomeGridDisplayPolicy.cs
  60. 0 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

+ 1 - 1
Web/package.json

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

+ 45 - 19
Web/src/views/aidop/api/kanbanData.ts

@@ -1,15 +1,6 @@
 import service from '/@/utils/request';
 import { resolveAidopTenantId, withAidopTenantParams } from './aidopTenant';
 
-export interface HomeL1Row {
-	moduleCode: string;
-	metricCode: string;
-	metricValue: number;
-	targetValue: number;
-	statusColor: string;
-	trendFlag: string;
-}
-
 export interface S8AlertRow {
 	time: string;
 	module: string;
@@ -700,16 +691,6 @@ export async function closeImprovementPlan(id: number) {
 	return (res.data ?? null) as SmartOpsImprovementPlan | null;
 }
 
-export async function fetchHomeL1(
-	factoryId = 1,
-	extra: KanbanExtraQuery = {}
-): Promise<HomeL1Row[]> {
-	const res = await service.get('/api/AidopKanban/home-l1', {
-		params: { factoryId, ...extra },
-	});
-	return (res.data ?? []) as HomeL1Row[];
-}
-
 export async function fetchS8Alerts(
 	factoryId = 1,
 	extra: KanbanExtraQuery = {}
@@ -758,6 +739,41 @@ export interface S8HomeTrendPayload {
 	points: S8HomeTrendPoint[];
 }
 
+export interface S8ModuleLamp {
+	code: string
+	name: string
+	businessState: 'CRITICAL' | 'WARNING' | 'NORMAL' | 'UNKNOWN' | string
+	monitorState: 'HEALTHY' | 'DEGRADED' | 'PAUSED' | 'UNMONITORED' | string
+	seriousCount: number
+	followCount: number
+	openCount: number
+	configuredRuleCount: number
+	enabledRuleCount: number
+	runnableRuleCount: number
+	latestRunAt: string | null
+	monitorReasonCode: string
+}
+
+export interface S8ModuleLampPayload {
+	asOf: string
+	items: S8ModuleLamp[]
+}
+
+export async function fetchS8ModuleLamps(): Promise<S8ModuleLampPayload | null> {
+	try {
+		const res = await service.get('/api/AidopKanban/s8-module-lamps', {
+			headers: { 'X-Silent-Error': '1' },
+		})
+		const d = res.data ?? {}
+		return {
+			asOf: d.asOf || '',
+			items: Array.isArray(d.items) ? d.items : [],
+		}
+	} catch {
+		return null
+	}
+}
+
 export async function fetchS8HomeTrend(
 	factoryId = 1,
 	extra: KanbanExtraQuery = {}
@@ -790,6 +806,16 @@ export interface S4HomeGridCell {
 	gapArrow: string;
 	achievementLevel: string;
 	formulaText?: string | null;
+	resultStatus?: string | null;
+	resultReason?: string | null;
+	bizDate?: string | null;
+	latestResultStatus?: string | null;
+	latestResultReason?: string | null;
+	latestBizDate?: string | null;
+	displayState?: string | null;
+	freshnessState?: string | null;
+	valueAgeDays?: number | null;
+	freshnessSlaDays?: number | null;
 }
 
 export interface S4HomeGridPayload {

+ 0 - 176
Web/src/views/aidop/kanban/data/homeModulesSync.ts

@@ -1,176 +0,0 @@
-import { reactive } from 'vue';
-import service from '/@/utils/request';
-
-/**
- * 首页 S1 / S3–S7 模块指标数据。
- * 默认值与 Demo 对齐;页面加载后会从数据库覆盖关键指标。
- */
-
-/** S1 产销协同 */
-export const homeS1 = reactive({
-  /** 对应首页:订单及时交付率 */
-  reviewSatisfactionPct: 75,
-  reviewSatisfactionTargetPct: 95,
-  /** 对应首页:订单交付周期(天) */
-  avgReviewCycleDays: 23,
-  avgReviewCycleTargetDays: 20,
-  /** 对应首页:成品库存周转天数 */
-  finishedGoodsInventoryDays: 14,
-  finishedGoodsInventoryTargetDays: 15,
-  /** 关键约束 · 物料齐套率 — 与首页及时交付率同口径数值 */
-  constraintMaterialKitPct: 75,
-  systemPressureGaugePct: 75,
-  /** 趋势图末点贴近首页满足率 */
-  trendSatisfaction: [71.5, 72, 72.5, 73, 73.5, 74, 74.5, 75],
-  trendMeanLine: 95
-});
-
-/** S3 供应协同(首页 liquid 与右侧两卡) */
-export const homeS3 = reactive({
-  demandKitRatePct: 75,
-  demandKitTargetPct: 95,
-  kitCycleDays: 18,
-  kitCycleTargetDays: 15,
-  materialInventoryTurnoverDays: 28,
-  materialInventoryTurnoverTargetDays: 30,
-  mrpTrendSeries: [70, 71, 72, 73, 74, 74.5, 75],
-  drillPrimaryEnd: 75
-});
-
-/** S4 采购执行 */
-export const homeS4 = reactive({
-  materialDeliveryQualityPct: 97,
-  materialDeliveryQualityTargetPct: 99,
-  materialOnTimeDeliveryPct: 99,
-  materialOnTimeDeliveryTargetPct: 99,
-  materialInventoryTurnoverDays: 28,
-  materialInventoryTurnoverTargetDays: 30,
-  trendMaterialTurnover: [26, 26.5, 27, 27.5, 28, 28, 28],
-  trendCategoryTurnover: [27, 27.2, 27.5, 27.8, 28, 28, 28],
-  /**
-   * 首页 S4 四宫格(参考设计稿;与详情页指标独立,避免强绑演示数)
-   * trendTone: good=绿色强调 bad=红橙警示;箭头 up/down 表示环比方向
-   */
-  homeGridKpis: [
-    {
-      key: 'deliveryCycle',
-      label: '物料交货周期',
-      value: '3.1',
-      unit: '天',
-      trend: '+0.3',
-      trendArrow: 'up',
-      trendTone: 'good'
-    },
-    {
-      key: 'deliverySat',
-      label: '物料交货满足率',
-      value: '93.8',
-      unit: '%',
-      trend: '-0.5',
-      trendArrow: 'down',
-      trendTone: 'bad'
-    },
-    {
-      key: 'procurementEff',
-      label: '物料采购人效',
-      value: '852',
-      unit: '',
-      trend: '+1%',
-      trendArrow: 'up',
-      trendTone: 'good'
-    },
-    {
-      key: 'inTransit',
-      label: '采购在途周转',
-      value: '8.5',
-      unit: '天',
-      trend: '-0.4',
-      trendArrow: 'down',
-      trendTone: 'good'
-    }
-  ]
-});
-
-/** S5 物料仓储(首页表格三行) */
-export const homeS5 = reactive({
-  matchOnTimeActualPct: 99,
-  matchOnTimeTargetPct: 98,
-  matchLeadTimeActualHrs: 14,
-  matchLeadTimeTargetHrs: 12,
-  rawWarehouseHeadcount: 7,
-  rawWarehouseHeadcountTarget: 10,
-  /** 与首页 S3/S4 物料库存周转天数对齐,用于详情顶栏「物料库存周转」 */
-  materialInventoryTurnoverDays: 28
-});
-
-/** S6 生产执行 */
-export const homeS6 = reactive({
-  firstPassYieldPct: 85,
-  firstPassYieldTargetPct: 98,
-  manufacturingEfficiency: 2.05,
-  woOnTimeDeliveryPct: 75,
-  woOnTimeDeliveryTargetPct: 95,
-  manufacturingEfficiencyTarget: 2.1
-});
-
-/** S7 成品仓储 */
-export const homeS7 = reactive({
-  shippingDefectPct: 1.0,
-  shippingDefectTargetPct: 0.8,
-  shipmentPlanRatePct: 100,
-  shipmentPlanRateTargetPct: 100,
-  orderShipMatchCycle: 9,
-  orderShipMatchCycleTarget: 8,
-  laborEfficiency: 7,
-  laborEfficiencyTarget: 10
-});
-
-let loaded = false;
-
-export async function loadHomeModuleMetrics(factoryId = 1): Promise<void> {
-  if (loaded) return;
-  try {
-    const res = await service.get('/api/AidopKanban/home-l1', { params: { factoryId } });
-    const list = (res.data ?? []) as Array<{
-      moduleCode?: string;
-      metricValue?: number;
-      targetValue?: number;
-    }>;
-    const byModule = Object.fromEntries(list.map((x) => [x.moduleCode, x]));
-
-    if (byModule.S1) {
-      homeS1.reviewSatisfactionPct = Number(byModule.S1.metricValue ?? homeS1.reviewSatisfactionPct);
-      homeS1.reviewSatisfactionTargetPct = Number(byModule.S1.targetValue ?? homeS1.reviewSatisfactionTargetPct);
-      homeS1.constraintMaterialKitPct = homeS1.reviewSatisfactionPct;
-      homeS1.systemPressureGaugePct = homeS1.reviewSatisfactionPct;
-      homeS1.trendSatisfaction = [71.5, 72, 72.5, 73, 73.5, 74, 74.5, homeS1.reviewSatisfactionPct];
-    }
-    if (byModule.S3) {
-      homeS3.demandKitRatePct = Number(byModule.S3.metricValue ?? homeS3.demandKitRatePct);
-      homeS3.demandKitTargetPct = Number(byModule.S3.targetValue ?? homeS3.demandKitTargetPct);
-      homeS3.drillPrimaryEnd = homeS3.demandKitRatePct;
-      homeS3.mrpTrendSeries = [70, 71, 72, 73, 74, 74.5, homeS3.demandKitRatePct];
-    }
-    if (byModule.S4) {
-      homeS4.materialOnTimeDeliveryPct = Number(byModule.S4.metricValue ?? homeS4.materialOnTimeDeliveryPct);
-      homeS4.materialOnTimeDeliveryTargetPct = Number(byModule.S4.targetValue ?? homeS4.materialOnTimeDeliveryTargetPct);
-      homeS4.homeGridKpis[1].value = `${homeS4.materialOnTimeDeliveryPct}`;
-    }
-    if (byModule.S5) {
-      homeS5.matchOnTimeActualPct = Number(byModule.S5.metricValue ?? homeS5.matchOnTimeActualPct);
-      homeS5.matchOnTimeTargetPct = Number(byModule.S5.targetValue ?? homeS5.matchOnTimeTargetPct);
-    }
-    if (byModule.S6) {
-      homeS6.firstPassYieldPct = Number(byModule.S6.metricValue ?? homeS6.firstPassYieldPct);
-      homeS6.firstPassYieldTargetPct = Number(byModule.S6.targetValue ?? homeS6.firstPassYieldTargetPct);
-      homeS6.woOnTimeDeliveryPct = homeS6.firstPassYieldPct;
-    }
-    if (byModule.S7) {
-      homeS7.shippingDefectPct = Number(byModule.S7.metricValue ?? homeS7.shippingDefectPct);
-      homeS7.shippingDefectTargetPct = Number(byModule.S7.targetValue ?? homeS7.shippingDefectTargetPct);
-    }
-    loaded = true;
-  } catch {
-    // keep demo defaults when api unavailable
-  }
-}

+ 9 - 13
Web/src/views/aidop/kanban/factualDisplay.spec.ts

@@ -54,19 +54,15 @@ describe('S8 正式页面:不得出现无来源的事实数字', () => {
 
 	// ── P1-2 ──
 
-	it('HomeModuleStatus_IsNotHardcoded:状态灯不得写死健康等级', () => {
+	it('HomeModuleStatus_IsNotHardcoded:状态灯初始为未知,颜色只来自接口', () => {
 		const code = homePage();
-		const idx = code.indexOf('const s8ModuleStatus');
-		expect(idx).toBeGreaterThan(-1);
-		const block = code.slice(idx, code.indexOf(']', idx) + 1);
-
-		// 七个模块条目都在(导航功能保留)
-		for (const m of ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']) expect(block).toContain(m);
-		// 但不得携带任何健康断言
-		for (const lvl of ["'critical'", "'warn'", "'orange'", "'ok'"]) {
-			expect(block).not.toContain(`level: ${lvl}`);
-		}
-		expect(block).toContain("level: 'unknown'");
+		expect(code).toContain("blankS8Lamps('LOADING')");
+		expect(code).toContain('fetchS8ModuleLamps');
+		expect(code).toContain("businessState: 'UNKNOWN'");
+		expect(code).not.toContain("businessState: 'NORMAL'");
+		expect(code).not.toContain("businessState: 'CRITICAL'");
+		expect(code).toContain("query: { stageCode: code }");
+		for (const m of ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']) expect(code).toContain(`'${m}'`);
 	});
 
 	it('HomeAlerts_DoNotUseHardcodedRows:报警列表初始值必须为空', () => {
@@ -88,6 +84,6 @@ describe('S8 正式页面:不得出现无来源的事实数字', () => {
 		const code = homePage();
 		expect(code).toContain('fetchS8Alerts');
 		// 真实数据为空时置空数组,不得回落到任何本地假数据
-		expect(code).toMatch(/s8TopAlarmsAll\.value\s*=\s*alerts\?\.length\s*\?\s*alerts\s*:\s*\[\]/);
+		expect(code).toMatch(/s8TopAlarmsAll\.value\s*=\s*Array\.isArray\(alerts\)\s*\?\s*alerts\s*:\s*\[\]/);
 	});
 });

+ 3 - 7
Web/src/views/aidop/kanban/s8.vue

@@ -62,16 +62,12 @@ const alarms = computed(() => {
 			desc: s ? '需 1h 内响应' : '暂无数据(未取到预警汇总)',
 			level: s && s.critical > 0 ? 'danger' : 'ok',
 		},
-		// S8-P1-FACTUAL-DISPLAY-1:原为写死的 `list.length ? '86%' : '100%'`(副标题还写着「目标 95%」),
-		// 没有任何接口或表支撑,纯字面量冒充业务指标。
-		// 本页两张兄弟卡的数据源是 ado_s8_alert_record,而该表全仓没有任何状态/关闭字段——
-		// 告警本身就没有「闭环」这个状态机,闭环率在此不可算。
-		// 既有的 closureRate(S8DashboardService)建在另一张表 ado_s8_exception 上,
-		// 跨表挪用等于给本页自造一套业务语义,按「无 Authority 不得发明公式」一律不显示。
+		// 今日/区间计数已经改读 ado_s8_exception 的新建数。
+		// 闭环率仍不在本页现算:不把另一套 dashboard 公式搬过来冒充这张卡。
 		{
 			title: '闭环完成率',
 			value: '—',
-			desc: '暂无数据(告警表无闭环状态)',
+			desc: '暂无数据(本页不计算闭环率)',
 			level: 'ok',
 		},
 	];

+ 48 - 1
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -19,6 +19,11 @@
 					<el-option v-for="m in filterOpts.modules" :key="m.value" :label="m.label" :value="m.value" />
 				</el-select>
 			</el-form-item>
+			<el-form-item label="监控阶段">
+				<el-select v-model="query.stageCode" clearable placeholder="全部" style="width: 120px" @change="onStageChange">
+					<el-option v-for="code in STAGE_CODES" :key="code" :label="code" :value="code" />
+				</el-select>
+			</el-form-item>
 			<!-- ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3j -->
 			<el-form-item label="订单号">
 				<el-input v-model="query.relatedObjectCode" placeholder="订单号 / 关联对象" clearable style="width: 180px" />
@@ -184,7 +189,7 @@
 </template>
 
 <script setup lang="ts" name="aidopS8ExceptionList">
-import { computed, onActivated, onMounted, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import { s8ExceptionApi, type S8ExceptionRow } from '../api/s8ExceptionApi';
@@ -228,6 +233,7 @@ const query = reactive({
 	severity: '' as string,
 	sceneCode: '' as string,
 	moduleCode: '' as string,
+	stageCode: '' as string,
 	deptId: undefined as number | undefined,
 	timeoutFlag: undefined as boolean | undefined,
 	ruleType: '' as string,
@@ -425,6 +431,7 @@ async function loadList() {
 			severity: query.severity || undefined,
 			sceneCode: query.sceneCode || undefined,
 			moduleCode: query.moduleCode || undefined,
+			stageCode: query.stageCode || undefined,
 			deptId: query.deptId || undefined,
 			keyword: query.keyword || undefined,
 			timeoutFlag: query.timeoutFlag,
@@ -452,6 +459,12 @@ function resetQuery() {
 	query.severity = '';
 	query.sceneCode = '';
 	query.moduleCode = '';
+	query.stageCode = '';
+	if (route.query.stageCode) {
+		const next: Record<string, any> = { ...route.query };
+		delete next.stageCode;
+		router.replace({ query: next });
+	}
 	query.deptId = undefined;
 	query.timeoutFlag = undefined;
 	query.ruleType = '';
@@ -479,7 +492,29 @@ function onStatusChange() {
 	if (query.status) query.statusBucket = '';
 }
 
+const STAGE_CODES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7'];
+
+function stageFromRoute(): string {
+	const raw = route.query.stageCode;
+	const code = (Array.isArray(raw) ? raw[0] : raw || '').trim().toUpperCase();
+	return STAGE_CODES.includes(code) ? code : '';
+}
+
+function syncStageFromRoute() {
+	query.stageCode = stageFromRoute();
+}
+
+function onStageChange() {
+	const next: Record<string, any> = { ...route.query };
+	if (query.stageCode) next.stageCode = query.stageCode;
+	else delete next.stageCode;
+	router.replace({ query: next });
+	query.page = 1;
+	void loadList();
+}
+
 onMounted(async () => {
+	syncStageFromRoute();
 	if (route.query.status) query.status = String(route.query.status);
 	if (route.query.bucket) query.statusBucket = String(route.query.bucket);
 	if (route.query.timeout === '1' || route.query.timeoutFlag === 'true') query.timeoutFlag = true;
@@ -509,8 +544,20 @@ onActivated(async () => {
 		activatedOnce.value = true;
 		return;
 	}
+	syncStageFromRoute();
 	await loadList();
 });
+
+watch(
+	() => route.query.stageCode,
+	async () => {
+		const next = stageFromRoute();
+		if (next === query.stageCode) return;
+		query.stageCode = next;
+		query.page = 1;
+		await loadList();
+	}
+);
 </script>
 
 <style scoped>

+ 24 - 2
Web/src/views/dashboard/components/HomeCardGridView.vue

@@ -4,7 +4,7 @@
     class="s4-kpi-grid"
     :class="{ 's4-kpi-grid--dense': props.cells.length > 4 }"
   >
-    <div v-for="cell in visibleCells" :key="cell.key" class="s4-kpi-cell">
+    <div v-for="cell in visibleCells" :key="cell.key" class="s4-kpi-cell" :class="{ 's4-kpi-cell--stale': cell.stale }">
       <el-tooltip
         v-if="cell.formulaText"
         placement="top"
@@ -17,10 +17,13 @@
       <div class="s4-kpi-body">
         <div class="s4-kpi-main">
           <div class="s4-kpi-value-row">
-            <span class="s4-kpi-value">{{ cell.value }}</span>
+            <span class="s4-kpi-value" :class="{ 's4-kpi-value--status': cell.valueKind === 'status' }">{{ cell.value }}</span>
             <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
           </div>
           <div v-if="cell.targetDisplay" class="s4-kpi-target">目标: {{ cell.targetDisplay }}</div>
+          <div v-if="cell.asOfText" class="s4-kpi-asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+          <div v-if="cell.latestText" class="s4-kpi-asof">{{ cell.latestText }}</div>
+          <div v-if="cell.policyText" class="s4-kpi-asof">{{ cell.policyText }}</div>
         </div>
         <span
           class="s4-kpi-trend"
@@ -156,6 +159,17 @@ const overflowCount = computed(() => {
   line-height: 1.3;
 }
 
+.s4-kpi-asof {
+  font-size: 10px;
+  font-weight: 500;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.s4-kpi-cell--stale {
+  opacity: 0.55;
+}
+
 .s4-kpi-value {
   font-size: clamp(16px, 3vw, 22px);
   font-weight: 800;
@@ -168,6 +182,14 @@ const overflowCount = computed(() => {
   font-size: clamp(14px, 2.2vw, 18px);
 }
 
+.s4-kpi-value.s4-kpi-value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .s4-kpi-unit {
   font-size: 11px;
   color: #e2e8f0;

+ 23 - 2
Web/src/views/dashboard/components/HomeCardTableView.vue

@@ -16,7 +16,7 @@
         </tr>
       </thead>
       <tbody>
-        <tr v-for="cell in cells" :key="cell.key">
+        <tr v-for="cell in cells" :key="cell.key" :class="{ 'tr--stale': cell.stale }">
           <td class="td-label" :title="cell.label">
             <el-tooltip
               v-if="cell.formulaText"
@@ -27,10 +27,13 @@
               <span class="td-label__text td-label__text--formula">{{ cell.label }}</span>
             </el-tooltip>
             <span v-else class="td-label__text">{{ cell.label }}</span>
+            <div v-if="cell.asOfText" class="td-asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+            <div v-if="cell.latestText" class="td-asof">{{ cell.latestText }}</div>
+            <div v-if="cell.policyText" class="td-asof">{{ cell.policyText }}</div>
           </td>
           <td class="td-num">{{ cell.targetDisplay || '—' }}</td>
           <td class="td-num">
-            <span class="td-num__value">{{ cell.value }}</span>
+            <span class="td-num__value" :class="{ 'td-num__value--status': cell.valueKind === 'status' }">{{ cell.value }}</span>
             <span v-if="cell.unit" class="td-num__unit">{{ cell.unit }}</span>
           </td>
           <td
@@ -156,6 +159,14 @@ defineProps({
   letter-spacing: 0.02em;
 }
 
+.td-num__value.td-num__value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .td-num__unit {
   font-size: 10px;
   color: #94a3b8;
@@ -177,6 +188,16 @@ defineProps({
   vertical-align: middle;
 }
 
+.td-asof {
+  font-size: 10px;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.tr--stale {
+  opacity: 0.55;
+}
+
 .td-gap--good { color: #4ade80; }
 .td-gap--warn { color: #facc15; }
 .td-gap--bad { color: #fb923c; }

+ 23 - 1
Web/src/views/dashboard/components/HomeCardTilesView.vue

@@ -12,6 +12,7 @@
         'tile--good': cell.trendTone === 'good',
         'tile--warn': cell.trendTone === 'warn',
         'tile--bad': cell.trendTone === 'bad',
+        'tile--stale': cell.stale,
       }"
     >
       <span class="tile__accent" aria-hidden="true"></span>
@@ -29,9 +30,12 @@
           目标值: <span class="tile__target-val">{{ cell.targetDisplay || '—' }}</span>
         </div>
         <div class="tile__main">
-          <span class="tile__value">{{ cell.value }}</span>
+          <span class="tile__value" :class="{ 'tile__value--status': cell.valueKind === 'status' }">{{ cell.value }}</span>
           <span v-if="cell.unit" class="tile__unit">{{ cell.unit }}</span>
         </div>
+        <div v-if="cell.asOfText" class="tile__asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
+        <div v-if="cell.latestText" class="tile__asof">{{ cell.latestText }}</div>
+        <div v-if="cell.policyText" class="tile__asof">{{ cell.policyText }}</div>
       </div>
     </div>
     <div v-if="overflowCount > 0" class="tiles-view__overflow" :title="`还有 ${overflowCount} 个指标未显示`">
@@ -192,6 +196,14 @@ const overflowCount = computed(() => {
   white-space: nowrap;
 }
 
+.tile__value.tile__value--status {
+  font-size: 11px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
+}
+
 .tile__unit {
   font-size: clamp(10px, 7cqi, 13px);
   color: #e2e8f0;
@@ -199,6 +211,16 @@ const overflowCount = computed(() => {
   white-space: nowrap;
 }
 
+.tile__asof {
+  font-size: 10px;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.tile--stale {
+  opacity: 0.55;
+}
+
 .tiles-view--dense .tile__body {
   gap: 2px;
   padding: 6px 8px 8px;

+ 2 - 65
Web/src/views/dashboard/components/HomeModuleKpiCard.vue

@@ -23,6 +23,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import { fetchHomeGrid } from '../../aidop/api/kanbanData'
 import { AIDOP_LAYOUT_SAVED, AIDOP_S4_LAYOUT_SAVED } from '../../aidop/kanban/utils/s4LayoutEvents'
 import { getStyleDef, DEFAULT_STYLE_CODE } from './homeCardStyles'
+import { mapHomeKpiItem } from './homeKpiPresentation'
 
 const props = defineProps({
   moduleCode: { type: String, required: true },
@@ -39,72 +40,8 @@ const apiItems = ref([])
 const layoutPattern = ref(DEFAULT_STYLE_CODE)
 const loading = ref(false)
 
-function fmtResult(item, unit) {
-  const status = String(item?.resultStatus || '')
-  if (status === 'SOURCE_UNSUPPORTED') return '数据源不支持'
-  if (status === 'NO_DATA') return '无业务数据'
-  return fmtValue(item?.currentValue, unit)
-}
-
-function fmtValue(v, unit) {
-  if (v == null || v === '') return '—'
-  const n = Number(v)
-  if (Number.isNaN(n)) return String(v)
-  if (unit === '%') return `${Math.round(n * 10) / 10}`
-  if (unit === '天') return `${Math.round(n * 10) / 10}`
-  if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
-  // 小数值(|n|<1)保留 3 位,避免 0.018 被 round 成 0.02 失真
-  if (Math.abs(n) < 1) return `${Math.round(n * 1000) / 1000}`
-  return `${Math.round(n * 100) / 100}`
-}
-
-function fmtTargetDisplay(v, unit) {
-  if (v == null || v === '') return ''
-  const n = Number(v)
-  if (Number.isNaN(n)) return String(v)
-  const num =
-    unit === '%'
-      ? `${Math.round(n * 10) / 10}`
-      : unit === '天'
-      ? `${Math.round(n * 10) / 10}`
-      : Math.abs(n - Math.round(n)) < 1e-9
-      ? `${Math.round(n)}`
-      : Math.abs(n) < 1
-      ? `${Math.round(n * 1000) / 1000}`
-      : `${Math.round(n * 100) / 100}`
-  if (unit === '%') return `${num}%`
-  if (unit === '天') return `${num} 天`
-  if (unit) return `${num} ${unit}`
-  return `${num}`
-}
-
-function mapItem(item) {
-  const arrow = item.gapArrow
-  const trendArrow = arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat'
-  const lv = String(item.achievementLevel || 'green')
-  const trendTone = lv === 'red' ? 'bad' : lv === 'yellow' ? 'warn' : 'good'
-  const unit = item.unit ?? ''
-  const formula =
-    item.formulaText != null && String(item.formulaText).trim()
-      ? String(item.formulaText).trim()
-      : ''
-  const reason = String(item?.resultReason || '').trim()
-  const ft = reason ? (formula ? `${formula}\n${reason}` : reason) : formula
-  return {
-    key: item.rowId || item.metricCode,
-    label: item.displayName || item.metricCode,
-    formulaText: ft,
-    value: fmtResult(item, unit),
-    unit,
-    targetDisplay: fmtTargetDisplay(item.targetValue, unit),
-    trend: item.gapLabel || '—',
-    trendArrow,
-    trendTone,
-  }
-}
-
 const cells = computed(() =>
-  Array.isArray(apiItems.value) ? apiItems.value.map(mapItem) : []
+  Array.isArray(apiItems.value) ? apiItems.value.map(mapHomeKpiItem) : []
 )
 
 const styleDef = computed(() => getStyleDef(layoutPattern.value))

+ 87 - 0
Web/src/views/dashboard/components/homeKpiPresentation.spec.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { mapHomeKpiItem, safeResultReason } from './homeKpiPresentation'
+
+describe('home kpi presentation', () => {
+	it('shows the last real number when the latest attempt has no data', () => {
+		const cell = mapHomeKpiItem({
+			metricCode: 'S6_L1_001',
+			displayName: '生产周期',
+			unit: '天',
+			currentValue: 3,
+			targetValue: 5,
+			bizDate: '2026-09-23',
+			displayState: 'LAST_KNOWN_VALUE',
+			latestResultStatus: 'NO_DATA',
+			latestBizDate: '2026-09-28',
+			freshnessState: 'STALE',
+			valueAgeDays: 6,
+			freshnessSlaDays: 7,
+			gapArrow: 'flat',
+			achievementLevel: 'green',
+		})
+		expect(cell.value).toBe('3')
+		expect(cell.valueKind).toBe('number')
+		expect(cell.value).not.toContain('无业务数据')
+		expect(cell.asOfText).toBe('数据截至 2026-09-23')
+		expect(cell.latestText).toContain('无业务数据')
+		expect(cell.stale).toBe(true)
+	})
+
+	it('does not dim a fresh card', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 1,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-09-29',
+			freshnessState: 'FRESH',
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+	})
+
+	it('does not apply a local seven-day rule to a monthly value', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 12,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-08-31',
+			freshnessState: 'FRESH',
+			valueAgeDays: 29,
+			freshnessSlaDays: 45,
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+		expect(cell.formulaText).toContain('SLA 45 天')
+	})
+
+	it('keeps a no-data status on one line', () => {
+		const cell = mapHomeKpiItem({
+			displayState: 'NO_DATA',
+			latestResultStatus: 'NO_DATA',
+			resultReason: '无完工',
+		})
+		expect(cell.valueKind).toBe('status')
+		expect(cell.value).toBe('无业务数据')
+		expect(cell.value).not.toMatch(/\n/)
+	})
+
+	it('replaces a reason that carries a connection secret', () => {
+		expect(safeResultReason('login failed Password=secret Server=10.0.0.1')).toBe('计算状态异常,请查看运行日志')
+		const cell = mapHomeKpiItem({
+			displayState: 'NO_DATA',
+			latestResultReason: 'Pwd=abc',
+		})
+		expect(cell.formulaText).toContain('计算状态异常,请查看运行日志')
+		expect(cell.formulaText).not.toContain('Pwd=')
+	})
+
+	it('shows a neutral note when freshness policy is missing', () => {
+		const cell = mapHomeKpiItem({
+			currentValue: 2,
+			displayState: 'CURRENT_VALUE',
+			bizDate: '2026-09-01',
+			freshnessState: 'POLICY_MISSING',
+			unit: '天',
+		})
+		expect(cell.stale).toBe(false)
+		expect(cell.policyText).toBe('未配置新鲜度规则')
+	})
+})

+ 162 - 0
Web/src/views/dashboard/components/homeKpiPresentation.ts

@@ -0,0 +1,162 @@
+export type HomeKpiValueKind = 'number' | 'status'
+
+const SECRET_REASON = /Password=|Pwd=|ConnectionString|Server=/i
+
+export function safeResultReason(reason: unknown): string {
+	const text = String(reason ?? '').trim()
+	if (!text) return ''
+	if (SECRET_REASON.test(text)) return '计算状态异常,请查看运行日志'
+	return text
+}
+
+function fmtNumber(v: unknown, unit: string): string {
+	if (v == null || v === '') return '—'
+	const n = Number(v)
+	if (Number.isNaN(n)) return String(v)
+	if (unit === '%' || unit === '天') return `${Math.round(n * 10) / 10}`
+	if (Math.abs(n - Math.round(n)) < 1e-9) return `${Math.round(n)}`
+	if (Math.abs(n) < 1) return `${Math.round(n * 1000) / 1000}`
+	return `${Math.round(n * 100) / 100}`
+}
+
+export function fmtTargetDisplay(v: unknown, unit: string): string {
+	if (v == null || v === '') return ''
+	const num = fmtNumber(v, unit)
+	if (num === '—') return ''
+	if (unit === '%') return `${num}%`
+	if (unit === '天') return `${num} 天`
+	if (unit) return `${num} ${unit}`
+	return num
+}
+
+function statusText(displayState: string): string {
+	if (displayState === 'SOURCE_UNSUPPORTED') return '数据源不支持'
+	return '无业务数据'
+}
+
+function latestPhrase(status: string): string {
+	if (status === 'SOURCE_UNSUPPORTED') return '数据源不支持'
+	if (status === 'NO_DATA' || status === '') return '无业务数据'
+	return status
+}
+
+export function mapHomeKpiItem(item: Record<string, any>) {
+	const unit = item?.unit ?? ''
+	const displayState = String(item?.displayState || '')
+	const hasNumber = displayState === 'CURRENT_VALUE' || displayState === 'LAST_KNOWN_VALUE'
+	const valueKind: HomeKpiValueKind = hasNumber ? 'number' : 'status'
+	const value = hasNumber ? fmtNumber(item?.currentValue, unit) : statusText(displayState || String(item?.resultStatus || ''))
+	const bizDate = String(item?.bizDate || '').slice(0, 10)
+	const latestDate = String(item?.latestBizDate || '').slice(0, 10)
+	const latestStatus = String(item?.latestResultStatus || '')
+	const freshnessState = String(item?.freshnessState || '')
+	const asOfText = hasNumber && /^\d{4}-\d{2}-\d{2}$/.test(bizDate) ? `数据截至 ${bizDate}` : ''
+	const latestText =
+		displayState === 'LAST_KNOWN_VALUE' && latestDate
+			? `最新一期:${latestPhrase(latestStatus)}(${latestDate})`
+			: ''
+	const policyText = freshnessState === 'POLICY_MISSING' ? '未配置新鲜度规则' : ''
+	const formula = item?.formulaText != null && String(item.formulaText).trim() ? String(item.formulaText).trim() : ''
+	const reason = safeResultReason(item?.latestResultReason || item?.resultReason)
+	const freshnessLine =
+		item?.valueAgeDays != null && item?.freshnessSlaDays != null
+			? `新鲜度:${item.valueAgeDays} 天 / SLA ${item.freshnessSlaDays} 天`
+			: ''
+	const tooltip = [formula, asOfText ? `最后真实值:${bizDate}` : '', latestDate ? `最新计算:${latestDate} / ${latestStatus || '—'} / ${reason}` : reason, freshnessLine, policyText]
+		.filter((line) => String(line || '').trim())
+		.join('\n')
+	const arrow = item?.gapArrow
+	return {
+		key: item?.rowId || item?.metricCode,
+		label: item?.displayName || item?.metricCode,
+		formulaText: tooltip,
+		value,
+		valueKind,
+		unit: valueKind === 'number' ? unit : '',
+		targetDisplay: valueKind === 'number' ? fmtTargetDisplay(item?.targetValue, unit) : '',
+		trend: item?.gapLabel || '—',
+		trendArrow: arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat',
+		trendTone: String(item?.achievementLevel || 'green') === 'red' ? 'bad' : String(item?.achievementLevel || '') === 'yellow' ? 'warn' : 'good',
+		asOfText,
+		latestText,
+		policyText,
+		stale: freshnessState === 'STALE',
+		staleHint: freshnessState === 'STALE' ? `数据陈旧:业务日 ${bizDate}` : policyText,
+	}
+}
+
+export function s8AlarmMessage(state: 'ok' | 'empty' | 'error'): string {
+	if (state === 'error') return '异常数据加载失败'
+	if (state === 'empty') return '暂无异常告警'
+	return ''
+}
+
+export function s8TrendNote(points: Array<{ count?: number }> | null): string {
+	if (points == null) return '异常数据加载失败'
+	if (points.length > 0 && points.every((p) => Number(p?.count || 0) === 0)) return '近 7 日无异常'
+	return ''
+}
+
+const S8_BUSINESS_TEXT: Record<string, string> = {
+	CRITICAL: '有严重异常',
+	WARNING: '有关注异常',
+	NORMAL: '无未关闭异常',
+	UNKNOWN: '尚不能判断业务是否正常',
+}
+
+const S8_MONITOR_TEXT: Record<string, string> = {
+	HEALTHY: '监控运行正常',
+	DEGRADED: '监控运行异常',
+	PAUSED: '监控已暂停',
+	UNMONITORED: '未配置监控规则',
+}
+
+const S8_REASON_TEXT: Record<string, string> = {
+	OK: '',
+	NO_RULE: '没有监控规则',
+	RULE_PAUSED: '规则已暂停',
+	RULE_DISABLED: '规则已停用',
+	NEVER_RUN: '规则尚未运行',
+	RUN_FAILED: '最近一次运行失败',
+	RUN_STALE: '运行结果已过期',
+	LOADING: '监控状态加载中',
+	LOAD_FAILED: '监控状态加载失败',
+}
+
+export function s8LampCoreClass(businessState: string): string {
+	if (businessState === 'CRITICAL') return 'critical'
+	if (businessState === 'WARNING') return 'warn'
+	if (businessState === 'NORMAL') return 'ok'
+	return 'unknown'
+}
+
+export function s8LampTooltip(item: {
+	code?: string
+	name?: string
+	businessState?: string
+	monitorState?: string
+	seriousCount?: number
+	followCount?: number
+	configuredRuleCount?: number
+	runnableRuleCount?: number
+	latestRunAt?: string | null
+	monitorReasonCode?: string
+}): string {
+	const reasonCode = String(item.monitorReasonCode || '')
+	const business = reasonCode === 'LOADING' || reasonCode === 'LOAD_FAILED'
+		? S8_REASON_TEXT[reasonCode]
+		: S8_BUSINESS_TEXT[String(item.businessState || '')] || S8_BUSINESS_TEXT.UNKNOWN
+	const monitor = reasonCode === 'LOADING' || reasonCode === 'LOAD_FAILED'
+		? ''
+		: S8_MONITOR_TEXT[String(item.monitorState || '')] || ''
+	const reason = S8_REASON_TEXT[reasonCode] || ''
+	return [
+		`${item.code || ''} ${item.name || ''}`.trim(),
+		business,
+		`严重 ${Number(item.seriousCount || 0)} / 关注 ${Number(item.followCount || 0)}`,
+		monitor,
+		reason && reason !== business ? reason : '',
+		`规则 ${Number(item.configuredRuleCount || 0)},可运行 ${Number(item.runnableRuleCount || 0)}`,
+		item.latestRunAt ? `最近运行 ${item.latestRunAt}` : '最近运行 —',
+	].filter((line) => String(line || '').trim()).join('\n')
+}

+ 0 - 180
Web/src/views/dashboard/data/homeModulesSync.ts

@@ -1,180 +0,0 @@
-import { reactive } from 'vue';
-import service from '/@/utils/request';
-
-/**
- * 首页 S1 / S3–S7 模块指标数据。
- * 默认值与 Demo 对齐;页面加载后会从数据库覆盖关键指标。
- */
-
-/** S1 产销协同 */
-export const homeS1 = reactive({
-  /** 对应首页:订单及时交付率 */
-  reviewSatisfactionPct: 75,
-  reviewSatisfactionTargetPct: 95,
-  /** 对应首页:订单交付周期(天) */
-  avgReviewCycleDays: 23,
-  avgReviewCycleTargetDays: 20,
-  /** 对应首页:成品库存周转天数 */
-  finishedGoodsInventoryDays: 14,
-  finishedGoodsInventoryTargetDays: 15,
-  /** 关键约束 · 物料齐套率 — 与首页及时交付率同口径数值 */
-  constraintMaterialKitPct: 75,
-  systemPressureGaugePct: 75,
-  /** 趋势图末点贴近首页满足率 */
-  trendSatisfaction: [71.5, 72, 72.5, 73, 73.5, 74, 74.5, 75],
-  trendMeanLine: 95
-});
-
-/** S3 供应协同(首页 liquid 与右侧两卡) */
-export const homeS3 = reactive({
-  demandKitRatePct: 75,
-  demandKitTargetPct: 95,
-  kitCycleDays: 18,
-  kitCycleTargetDays: 15,
-  materialInventoryTurnoverDays: 28,
-  materialInventoryTurnoverTargetDays: 30,
-  mrpTrendSeries: [70, 71, 72, 73, 74, 74.5, 75],
-  drillPrimaryEnd: 75
-});
-
-/** S4 采购执行 */
-export const homeS4 = reactive({
-  materialDeliveryQualityPct: 97,
-  materialDeliveryQualityTargetPct: 99,
-  materialOnTimeDeliveryPct: 99,
-  materialOnTimeDeliveryTargetPct: 99,
-  materialInventoryTurnoverDays: 28,
-  materialInventoryTurnoverTargetDays: 30,
-  trendMaterialTurnover: [26, 26.5, 27, 27.5, 28, 28, 28],
-  trendCategoryTurnover: [27, 27.2, 27.5, 27.8, 28, 28, 28],
-  /**
-   * 首页 S4 四宫格(参考设计稿;与详情页指标独立,避免强绑演示数)
-   * trendTone: good=绿色强调 bad=红橙警示;箭头 up/down 表示环比方向
-   */
-  homeGridKpis: [
-    {
-      key: 'deliveryCycle',
-      label: '物料交货周期',
-      value: '3.1',
-      unit: '天',
-      targetDisplay: '3 天',
-      trend: '+0.3',
-      trendArrow: 'up',
-      trendTone: 'good'
-    },
-    {
-      key: 'deliverySat',
-      label: '物料交货满足率',
-      value: '99',
-      unit: '%',
-      targetDisplay: '99.5%',
-      trend: '-0.5',
-      trendArrow: 'down',
-      trendTone: 'bad'
-    },
-    {
-      key: 'procurementEff',
-      label: '物料采购人效',
-      value: '852',
-      unit: '',
-      targetDisplay: '850',
-      trend: '+1%',
-      trendArrow: 'up',
-      trendTone: 'good'
-    },
-    {
-      key: 'inTransit',
-      label: '采购在途周转',
-      value: '8.5',
-      unit: '天',
-      targetDisplay: '6 天',
-      trend: '-0.5',
-      trendArrow: 'down',
-      trendTone: 'bad'
-    }
-  ]
-});
-
-/** S5 物料仓储(首页表格三行) */
-export const homeS5 = reactive({
-  matchOnTimeActualPct: 99,
-  matchOnTimeTargetPct: 98,
-  matchLeadTimeActualHrs: 14,
-  matchLeadTimeTargetHrs: 12,
-  rawWarehouseHeadcount: 7,
-  rawWarehouseHeadcountTarget: 10,
-  /** 与首页 S3/S4 物料库存周转天数对齐,用于详情顶栏「物料库存周转」 */
-  materialInventoryTurnoverDays: 28
-});
-
-/** S6 生产执行 */
-export const homeS6 = reactive({
-  firstPassYieldPct: 85,
-  firstPassYieldTargetPct: 98,
-  manufacturingEfficiency: 2.05,
-  woOnTimeDeliveryPct: 75,
-  woOnTimeDeliveryTargetPct: 95,
-  manufacturingEfficiencyTarget: 2.1
-});
-
-/** S7 成品仓储 */
-export const homeS7 = reactive({
-  shippingDefectPct: 1.0,
-  shippingDefectTargetPct: 0.8,
-  shipmentPlanRatePct: 100,
-  shipmentPlanRateTargetPct: 100,
-  orderShipMatchCycle: 9,
-  orderShipMatchCycleTarget: 8,
-  laborEfficiency: 7,
-  laborEfficiencyTarget: 10
-});
-
-let loaded = false;
-
-export async function loadHomeModuleMetrics(tenantId = 1, factoryId = 1): Promise<void> {
-  if (loaded) return;
-  try {
-    const res = await service.get('/api/AidopKanban/home-l1', { params: { tenantId, factoryId } });
-    const list = (res.data ?? []) as Array<{
-      moduleCode?: string;
-      metricValue?: number;
-      targetValue?: number;
-    }>;
-    const byModule = Object.fromEntries(list.map((x) => [x.moduleCode, x]));
-
-    if (byModule.S1) {
-      homeS1.reviewSatisfactionPct = Number(byModule.S1.metricValue ?? homeS1.reviewSatisfactionPct);
-      homeS1.reviewSatisfactionTargetPct = Number(byModule.S1.targetValue ?? homeS1.reviewSatisfactionTargetPct);
-      homeS1.constraintMaterialKitPct = homeS1.reviewSatisfactionPct;
-      homeS1.systemPressureGaugePct = homeS1.reviewSatisfactionPct;
-      homeS1.trendSatisfaction = [71.5, 72, 72.5, 73, 73.5, 74, 74.5, homeS1.reviewSatisfactionPct];
-    }
-    if (byModule.S3) {
-      homeS3.demandKitRatePct = Number(byModule.S3.metricValue ?? homeS3.demandKitRatePct);
-      homeS3.demandKitTargetPct = Number(byModule.S3.targetValue ?? homeS3.demandKitTargetPct);
-      homeS3.drillPrimaryEnd = homeS3.demandKitRatePct;
-      homeS3.mrpTrendSeries = [70, 71, 72, 73, 74, 74.5, homeS3.demandKitRatePct];
-    }
-    if (byModule.S4) {
-      homeS4.materialOnTimeDeliveryPct = Number(byModule.S4.metricValue ?? homeS4.materialOnTimeDeliveryPct);
-      homeS4.materialOnTimeDeliveryTargetPct = Number(byModule.S4.targetValue ?? homeS4.materialOnTimeDeliveryTargetPct);
-      homeS4.homeGridKpis[1].value = `${homeS4.materialOnTimeDeliveryPct}`;
-    }
-    if (byModule.S5) {
-      homeS5.matchOnTimeActualPct = Number(byModule.S5.metricValue ?? homeS5.matchOnTimeActualPct);
-      homeS5.matchOnTimeTargetPct = Number(byModule.S5.targetValue ?? homeS5.matchOnTimeTargetPct);
-    }
-    if (byModule.S6) {
-      homeS6.firstPassYieldPct = Number(byModule.S6.metricValue ?? homeS6.firstPassYieldPct);
-      homeS6.firstPassYieldTargetPct = Number(byModule.S6.targetValue ?? homeS6.firstPassYieldTargetPct);
-      homeS6.woOnTimeDeliveryPct = homeS6.firstPassYieldPct;
-    }
-    if (byModule.S7) {
-      homeS7.shippingDefectPct = Number(byModule.S7.metricValue ?? homeS7.shippingDefectPct);
-      homeS7.shippingDefectTargetPct = Number(byModule.S7.targetValue ?? homeS7.shippingDefectTargetPct);
-    }
-    loaded = true;
-  } catch {
-    // keep demo defaults when api unavailable
-  }
-}

+ 0 - 109
Web/src/views/dashboard/data/s2Kpis.ts

@@ -1,109 +0,0 @@
-import { reactive } from 'vue';
-import service from '/@/utils/request';
-
-/**
- * 首页 S2 卡片与 S2 详情页顶部 KPI 共用。
- */
-export const s2HomeKpiList = reactive([
-  {
-    key: 'satisfaction',
-    label: '订单排程满足率',
-    valueText: '85%',
-    targetText: '目标 99%',
-    statusClass: 'status-red'
-  },
-  {
-    key: 'cycle',
-    label: '订单排程周期',
-    valueText: '22 天',
-    targetText: '目标 20 天',
-    statusClass: 'status-yellow'
-  },
-  {
-    key: 'wip',
-    label: '在制库存周转天数',
-    valueText: '23 天',
-    targetText: '目标 18 天',
-    statusClass: 'status-yellow'
-  },
-  {
-    key: 'labor',
-    label: '订单排程人效',
-    valueText: '18.5 单/人·日',
-    targetText: '目标 20',
-    statusClass: 'status-yellow'
-  }
-]);
-
-/** 详情页第一行 KPI(顺序与首页一致) */
-export const s2DetailTopCards = reactive([
-  {
-    key: 'satisfaction',
-    title: '订单排程满足率',
-    valueMain: '85',
-    valueUnit: '%',
-    trendText: '↓3%',
-    trendClass: 'down',
-    barWrapClass: 'danger',
-    barWidthPct: 86
-  },
-  {
-    key: 'cycle',
-    title: '订单排程周期',
-    valueMain: '22',
-    valueUnit: '天',
-    trendText: '↑2',
-    trendClass: 'up',
-    barWrapClass: '',
-    barWidthPct: 91
-  },
-  {
-    key: 'wip',
-    title: '在制库存周转天数',
-    valueMain: '23',
-    valueUnit: '天',
-    trendText: '↑1',
-    trendClass: 'up',
-    barWrapClass: 'danger',
-    barWidthPct: 78
-  },
-  {
-    key: 'labor',
-    title: '订单排程人效',
-    valueMain: '18.5',
-    valueUnit: '单/人·日',
-    trendText: '↓0.3',
-    trendClass: 'down',
-    barWrapClass: 'warning',
-    barWidthPct: 93
-  }
-]);
-
-/** 趋势图末点与首页 KPI 对齐(7 日) */
-export const s2TrendSeries = reactive({
-  cycleDays: [20, 20.5, 21, 21.5, 22, 22, 22],
-  satisfactionPct: [90, 89, 88, 87, 86, 85.5, 85]
-});
-
-let loaded = false;
-
-export async function loadS2Kpis(tenantId = 1, factoryId = 1): Promise<void> {
-  if (loaded) return;
-  try {
-    const res = await service.get('/api/AidopKanban/home-l1', { params: { tenantId, factoryId } });
-    const list = (res.data ?? []) as Array<{ moduleCode?: string; metricValue?: number; targetValue?: number }>;
-    const s2 = list.find((x) => x.moduleCode === 'S2');
-    if (s2) {
-      const v = Number(s2.metricValue ?? 85);
-      const t = Number(s2.targetValue ?? 99);
-      s2HomeKpiList[0].valueText = `${v}%`;
-      s2HomeKpiList[0].targetText = `目标 ${t}%`;
-      s2DetailTopCards[0].valueMain = `${v}`;
-      s2DetailTopCards[0].barWidthPct = Math.max(5, Math.min(100, Math.round(v)));
-      s2TrendSeries.satisfactionPct = [90, 89, 88, 87, 86, 85.5, v];
-    }
-    loaded = true;
-  } catch {
-    // keep defaults
-  }
-}

+ 104 - 0
Web/src/views/dashboard/home.s8-empty.spec.ts

@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { s8AlarmMessage, s8LampCoreClass, s8LampTooltip, s8TrendNote } from './components/homeKpiPresentation'
+
+describe('S8 home empty states', () => {
+	it('says there are no alarms when the list is empty', () => {
+		expect(s8AlarmMessage('empty')).toBe('暂无异常告警')
+	})
+
+	it('does not call a failed request an empty list', () => {
+		expect(s8AlarmMessage('error')).toBe('异常数据加载失败')
+		expect(s8AlarmMessage('error')).toContain('加载失败')
+	})
+
+	it('keeps a real zero trend and says the week had no exceptions', () => {
+		expect(s8TrendNote([{ count: 0 }, { count: 0 }])).toBe('近 7 日无异常')
+		expect(s8TrendNote(null)).toBe('异常数据加载失败')
+	})
+
+	it('maps only a healthy zero-exception module to green', () => {
+		expect(s8LampCoreClass('CRITICAL')).toBe('critical')
+		expect(s8LampCoreClass('WARNING')).toBe('warn')
+		expect(s8LampCoreClass('NORMAL')).toBe('ok')
+		expect(s8LampCoreClass('UNKNOWN')).toBe('unknown')
+	})
+
+	it('explains a red lamp whose monitor is paused', () => {
+		const text = s8LampTooltip({
+			code: 'S4',
+			name: '采购执行',
+			businessState: 'CRITICAL',
+			monitorState: 'PAUSED',
+			seriousCount: 29,
+			followCount: 0,
+			configuredRuleCount: 1,
+			runnableRuleCount: 0,
+			monitorReasonCode: 'RULE_DISABLED',
+		})
+		expect(text).toContain('有严重异常')
+		expect(text).toContain('监控已暂停')
+		expect(text).toContain('规则已停用')
+		expect(text).toContain('严重 29 / 关注 0')
+		expect(text).not.toContain('无未关闭异常')
+	})
+
+	it('includes the latest run time in the lamp tooltip', () => {
+		const text = s8LampTooltip({
+			code: 'S1',
+			businessState: 'WARNING',
+			monitorState: 'HEALTHY',
+			seriousCount: 0,
+			followCount: 2,
+			latestRunAt: '2026-09-30 00:30:00',
+			monitorReasonCode: 'OK',
+		})
+		expect(text).toContain('最近运行 2026-09-30 00:30:00')
+		expect(text).toContain('有关注异常')
+	})
+
+	it('does not call an unmonitored module normal', () => {
+		const text = s8LampTooltip({
+			code: 'S3',
+			businessState: 'UNKNOWN',
+			monitorState: 'UNMONITORED',
+			monitorReasonCode: 'NO_RULE',
+		})
+		expect(text).toContain('未配置监控规则')
+		expect(text).not.toContain('无未关闭异常')
+	})
+
+	it('says the lamps are still loading instead of showing a result', () => {
+		expect(s8LampTooltip({ code: 'S1', businessState: 'UNKNOWN', monitorReasonCode: 'LOADING' })).toContain('加载中')
+		expect(s8LampTooltip({ code: 'S1', monitorReasonCode: 'LOAD_FAILED' })).toContain('加载失败')
+	})
+
+	it('drills a lamp into the exception list for that stage', () => {
+		const home = readFileSync(resolve(__dirname, './home.vue'), 'utf8')
+			.replace(/\/\*[\s\S]*?\*\//g, '')
+			.replace(/^\s*\/\/.*$/gm, '')
+			.replace(/<!--[\s\S]*?-->/g, '')
+		const list = readFileSync(resolve(__dirname, '../aidop/s8/exceptions/S8ExceptionListPage.vue'), 'utf8')
+			.replace(/\/\*[\s\S]*?\*\//g, '')
+			.replace(/^\s*\/\/.*$/gm, '')
+			.replace(/<!--[\s\S]*?-->/g, '')
+		expect(home).toContain("['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']")
+		expect(home).toContain("blankS8Lamps('LOAD_FAILED')")
+		expect(home).not.toContain("businessState: 'NORMAL'")
+		expect(home).toContain("path: '/aidop/s8/exceptions'")
+		expect(home).toContain('stageCode: code')
+		expect(list).toContain('stageCode: query.stageCode')
+		expect(list).toContain('syncStageFromRoute')
+		expect(list).toContain('route.query.stageCode')
+		expect(list).toContain('监控阶段')
+	})
+
+	it('does not restore the deleted demo alarms', () => {
+		const raw = readFileSync(resolve(__dirname, './home.vue'), 'utf8')
+		const home = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/<!--[\s\S]*?-->/g, '')
+		expect(home).not.toContain('机台 A-04')
+		expect(home).toContain('s8AlarmMessage')
+		expect(home).toContain("s8AlarmState.value = 'error'")
+	})
+})

+ 113 - 28
Web/src/views/dashboard/home.vue

@@ -106,6 +106,7 @@
               <span class="s8-alarm-header__text">重点报警信息</span>
             </div>
             <div class="s8-alarm-list">
+              <div v-if="s8AlarmMessageText" class="s8-alarm-empty">{{ s8AlarmMessageText }}</div>
               <div
                 v-for="(log, idx) in displayS8Alarms"
                 :key="idx"
@@ -127,6 +128,7 @@
           <div class="s8-bottom-band">
             <div class="s8-chart-wrap">
               <div id="chart-s8-trend" class="chart-s8" />
+              <div v-if="s8TrendNoteText" class="s8-trend-note">{{ s8TrendNoteText }}</div>
             </div>
             <div class="s8-modules">
               <button
@@ -134,11 +136,14 @@
                 :key="m.code"
                 type="button"
                 class="s8-mod"
-                :title="m.level === 'unknown' ? `${m.code} 未监测(暂无模块健康数据)` : m.code"
-                @click.stop="navigateTo(m.route)"
+                :title="s8LampTooltip(m)"
+                @click.stop="openS8Exceptions(m.code)"
               >
-                <span class="s8-mod__dot" :class="`s8-mod__dot--${m.level}`" />
-                <span class="s8-mod__label" :class="`s8-mod__label--${m.level}`">{{ m.code }}</span>
+                <span class="s8-mod__dot" :class="[`s8-mod__dot--${s8LampCoreClass(m.businessState)}`, `s8-mod__ring--${(m.monitorState || 'unmonitored').toLowerCase()}`]">
+                  <span v-if="m.monitorState === 'PAUSED'" class="s8-mod__badge">停</span>
+                  <span v-else-if="m.monitorState === 'DEGRADED'" class="s8-mod__badge">!</span>
+                </span>
+                <span class="s8-mod__label" :class="`s8-mod__label--${s8LampCoreClass(m.businessState)}`">{{ m.code }}</span>
               </button>
             </div>
           </div>
@@ -217,7 +222,8 @@ import {
   User
 } from '@element-plus/icons-vue'
 import * as echarts from 'echarts'
-import { fetchS8Alerts, fetchS8HomeTrend } from '../aidop/api/kanbanData'
+import { fetchS8Alerts, fetchS8HomeTrend, fetchS8ModuleLamps } from '../aidop/api/kanbanData'
+import { s8AlarmMessage, s8TrendNote, s8LampTooltip, s8LampCoreClass } from './components/homeKpiPresentation'
 import DetailQueryBar from '../aidop/kanban/components/DetailQueryBar.vue'
 import SmartOpsBaseQueryFields from '../aidop/kanban/components/SmartOpsBaseQueryFields.vue'
 import {
@@ -254,26 +260,26 @@ function tickHeaderClock() {
   headerDateTime.value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
 }
 
-/**
- * S1–S7 模块入口。
- *
- * S8-P1-FACTUAL-DISPLAY-1:原先每项带一个写死的 level(S2=critical / S4=warn / S6=orange),
- * 注释是「与参考图一致」——即直接从效果图抄来的颜色,从不请求、从不更新。
- * 用户看到的是一组**永远不变、却长得像实时健康状态**的红黄灯。
- *
- * 后端当前没有任何模块健康 Authority(全仓无 module health / system status 服务),
- * 因此不再给出健康断言:这些按钮只保留它们真正具备的功能——跳转到对应模块页。
- * 状态语义统一落 'unknown'(中性灰 + 「未监测」提示),等真实健康源就位后再接。
- */
-const s8ModuleStatus = [
-  { code: 'S1', level: 'unknown', route: 's1' },
-  { code: 'S2', level: 'unknown', route: 's2' },
-  { code: 'S3', level: 'unknown', route: 's3' },
-  { code: 'S4', level: 'unknown', route: 's4' },
-  { code: 'S5', level: 'unknown', route: 's5' },
-  { code: 'S6', level: 'unknown', route: 's6' },
-  { code: 'S7', level: 'unknown', route: 's7' }
-]
+const S8_STAGE_CODES = ['S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7']
+
+function blankS8Lamps(reason) {
+  return S8_STAGE_CODES.map((code) => ({
+    code,
+    name: '',
+    businessState: 'UNKNOWN',
+    monitorState: 'UNMONITORED',
+    seriousCount: 0,
+    followCount: 0,
+    openCount: 0,
+    configuredRuleCount: 0,
+    enabledRuleCount: 0,
+    runnableRuleCount: 0,
+    latestRunAt: null,
+    monitorReasonCode: reason
+  }))
+}
+
+const s8ModuleStatus = ref(blankS8Lamps('LOADING'))
 
 /**
  * TOP 报警列表。真实数据由 loadS8Data() → fetchS8Alerts 覆盖(空/异常时置 [])。
@@ -283,6 +289,9 @@ const s8ModuleStatus = [
  * 即请求返回之前真实渲染出来,与随后的真实报警长得一模一样。初始值改为空。
  */
 const s8TopAlarmsAll = ref([])
+const s8AlarmState = ref('ok')
+const s8TrendNoteText = ref('')
+const s8AlarmMessageText = computed(() => s8AlarmMessage(s8AlarmState.value))
 
 const displayS8Alarms = computed(() => {
   const q = dashboardBaseQuery.value
@@ -309,15 +318,38 @@ function onDashboardReset() {
  * moduleCode + kpiExtraParams 拉取和响应 AIDOP_LAYOUT_SAVED 事件;
  * 本页仅负责 S8 的报警列表。
  */
+async function loadS8ModuleLamps() {
+  try {
+    const payload = await fetchS8ModuleLamps()
+    const items = Array.isArray(payload?.items) ? payload.items : null
+    if (!items) {
+      s8ModuleStatus.value = blankS8Lamps('LOAD_FAILED')
+      return
+    }
+    const byCode = new Map(items.map((item) => [item.code, item]))
+    const missing = blankS8Lamps('NO_RULE')
+    s8ModuleStatus.value = S8_STAGE_CODES.map((code, index) => byCode.get(code) || missing[index])
+  } catch {
+    s8ModuleStatus.value = blankS8Lamps('LOAD_FAILED')
+  }
+}
+
+function openS8Exceptions(code) {
+  router.push({ path: '/aidop/s8/exceptions', query: { stageCode: code } })
+}
+
 async function loadS8Data() {
   const extra = baseQueryToApiParams(dashboardBaseQuery.value)
+  const lamps = loadS8ModuleLamps()
   try {
     const alerts = await fetchS8Alerts(1, extra)
-    s8TopAlarmsAll.value = alerts?.length ? alerts : []
+    s8TopAlarmsAll.value = Array.isArray(alerts) ? alerts : []
+    s8AlarmState.value = s8TopAlarmsAll.value.length ? 'ok' : 'empty'
   } catch {
     s8TopAlarmsAll.value = []
+    s8AlarmState.value = 'error'
   }
-  await renderS8Trend(extra)
+  await Promise.all([lamps, renderS8Trend(extra)])
 }
 
 let chartS8 = null
@@ -349,6 +381,7 @@ async function renderS8Trend(extra) {
   const chart = ensureS8Chart()
   if (!chart) return
   const trend = await fetchS8HomeTrend(1, extra || {})
+  s8TrendNoteText.value = s8TrendNote(trend ? trend.points || [] : null)
   const labels = (trend?.points || []).map((p) => p.date)
   const values = (trend?.points || []).map((p) => Number(p.count || 0))
   const maxVal = Math.max(0, ...values)
@@ -930,6 +963,14 @@ onUnmounted(() => {
   color: #fecaca;
 }
 
+.s8-alarm-empty,
+.s8-trend-note {
+  color: #94a3b8;
+  font-size: 12px;
+  line-height: 1.4;
+  padding: 8px 4px;
+}
+
 .s8-alarm-list {
   flex: 1;
   min-height: 0;
@@ -1060,6 +1101,7 @@ onUnmounted(() => {
 }
 
 .s8-mod__dot {
+  position: relative;
   width: 14px;
   height: 14px;
   border-radius: 50%;
@@ -1092,7 +1134,50 @@ onUnmounted(() => {
    刻意不发光、不取红黄绿任一色——它表示「未监测」,不是「正常」。 */
 .s8-mod__dot--unknown {
   background: #475569;
-  box-shadow: 0 0 0 2px rgba(71, 85, 105, 0.35);
+  box-shadow: none;
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--ok {
+  box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25);
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--critical {
+  box-shadow:
+    0 0 10px 3px rgba(239, 68, 68, 0.85),
+    0 0 20px 6px rgba(239, 68, 68, 0.45);
+}
+
+.s8-mod__ring--healthy.s8-mod__dot--warn {
+  box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.35);
+}
+
+.s8-mod__ring--degraded {
+  outline: 2px dashed #f97316;
+  outline-offset: 2px;
+}
+
+.s8-mod__ring--paused {
+  outline: 2px dashed #94a3b8;
+  outline-offset: 2px;
+}
+
+.s8-mod__ring--unmonitored {
+  box-shadow: none;
+}
+
+.s8-mod__badge {
+  position: absolute;
+  right: -6px;
+  top: -6px;
+  min-width: 12px;
+  height: 12px;
+  padding: 0 2px;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #e2e8f0;
+  font-size: 9px;
+  line-height: 12px;
+  text-align: center;
 }
 
 .s8-mod__label {

+ 1295 - 0
doc/plan/S8九宫格异常权威数据与模块灯态根治执行任务书.md

@@ -0,0 +1,1295 @@
+# S8 九宫格异常权威数据与模块灯态根治执行任务书
+
+> 文档状态:已执行  
+> 编制日期:2026-09-30  
+> 执行版本:server `1.0.596`、Web `2.4.415`  
+> 当前基线:server `1.0.595`、Web `2.4.414`、HEAD `294e32e1d`  
+> 适用入口:FUNC-S9-005 九宫格智慧运营看板、S8 异常监控看板、FUNC-S8-008 异常列表  
+> 目标读者:可直接修改本仓库并执行测试、构建、提交和推送的其它大模型/开发者
+
+---
+
+## 0. 执行前硬约束
+
+1. 先读取 `AGENTS.md` 及 `.cursor/rules/` 下全部 `alwaysApply` 规则。
+2. 修改前重新核对 HEAD、版本和全部工作区状态;不得覆盖或提交他人改动。
+3. 按仓库协作规则先向用户列出精确改动清单,得到确认后再应用补丁。
+4. 本任务默认交付根治方案,禁止:
+   - 给 `ado_s8_alert_record` 回填数据;
+   - 新增异常到旧表的双写链路;
+   - 把“未监测”“规则暂停”“检测过期”显示成绿色;
+   - 用 KPI 是否达标推断 S8 监控灯态;
+   - 用 `last_detected_at` 或每次轮询产生的 `REFRESHED` 次数统计“每日新增异常”;
+   - 直接修改 `ado_s8_exception`、`ado_s8_watch_rule` 业务数据来让页面变色;
+   - 复制其它租户异常或监控规则;
+   - 在前端写死 S1–S7 红黄绿状态。
+5. 若实际基线已不再是 server `1.0.595` / Web `2.4.414`,版本号按当前版本顺延,不得覆盖或降号。
+6. 本任务涉及前后端运行代码。执行提交时:
+   - server patch `+1`;
+   - Web patch `+1`;
+   - 锁文件若因版本更新发生变化,须与 `package.json` 一并提交。
+7. 用户说“提交”即 commit + push。不得使用 `git add .`,不得提交:
+   - `bin/`、`obj/`、`TestResults/`;
+   - `_tmp_*.log`、`frontend-dev.log`;
+   - `~$*.docx`;
+   - 口令、连接串、JWT、Cookie 或其它秘密。
+
+---
+
+## 1. 任务目标
+
+本任务同时根治两个同源问题:
+
+1. 九宫格 S8 区域显示“暂无异常告警”和 7 日 0 趋势,但 S8 当前异常主表实际存在未关闭异常。
+2. S8 区域内 S1–S7 七个模块灯全部硬编码为 `unknown`,不能表达:
+   - 当前是否有未关闭异常;
+   - 是否存在严重异常;
+   - 模块是否配置监控;
+   - 监控规则是否正常、暂停、失败或陈旧。
+
+最终要求:
+
+- S8 告警列表、区间汇总、每日趋势统一读取当前异常权威表 `ado_s8_exception`。
+- S1–S7 灯态使用“双维度表达”:
+  - **灯芯**:业务异常状态;
+  - **外圈/徽标**:监控运行状态。
+- 用户点击模块灯后进入 FUNC-S8-008 异常列表,并按对应 `stageCode=S1..S7` 自动过滤。
+- 所有数字都来自真实异常和真实规则状态,不造数、不回填旧表。
+
+---
+
+## 2. 已取证事实
+
+### 2.1 代码层
+
+#### 2.1.1 九宫格 S8 当前读取旧表
+
+调用链:
+
+```text
+Web/src/views/dashboard/home.vue
+  ├─ fetchS8Alerts()
+  │    └─ GET /api/AidopKanban/s8-alerts
+  └─ fetchS8HomeTrend()
+       └─ GET /api/AidopKanban/s8-home-trend
+```
+
+后端 `AidopKanbanController.cs` 中:
+
+- `GetS8Alerts` 查询 `ado_s8_alert_record`;
+- `GetS8AlertSummary` 查询 `ado_s8_alert_record`;
+- `GetS8HomeTrend` 查询 `ado_s8_alert_record`。
+
+全仓现状:
+
+- `ado_s8_alert_record` 只有读取方;
+- 当前 `server/` 下无正常业务 INSERT 产出链;
+- 因而该表不能作为当前 S8 告警 Authority。
+
+#### 2.1.2 当前异常 Authority 已是 `ado_s8_exception`
+
+下列现行能力均读取或写入 `ado_s8_exception`:
+
+- `S8MonitoringService`;
+- `S8DashboardService`;
+- `S8ExceptionService`;
+- `S8KpiSnapshotService`;
+- `S8OrderFlowService`;
+- 智慧诊断 S8 证据;
+- 规则检测、异常建单、处理闭环。
+
+实体:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs
+```
+
+关键字段:
+
+- `tenant_id`
+- `factory_id`(S8 tenant-only 终态下恒为全局工厂值 `0`)
+- `title`
+- `description`
+- `status`
+- `severity`
+- `created_at`
+- `closed_at`
+- `last_detected_at`
+- `module_code`
+- `stage_code`
+- `related_object_code`
+- `source_object_type`
+- `source_object_id`
+- `source_payload`
+
+#### 2.1.3 七个灯当前全部硬编码
+
+`Web/src/views/dashboard/home.vue` 中 `s8ModuleStatus` 固定为:
+
+```ts
+[
+  { code: 'S1', level: 'unknown', route: 's1' },
+  // ...
+  { code: 'S7', level: 'unknown', route: 's7' }
+]
+```
+
+现状行为:
+
+- 颜色统一中性灰;
+- tooltip 为“未监测(暂无模块健康数据)”;
+- 不请求后端;
+- 点击只跳普通模块页面。
+
+#### 2.1.4 异常列表后端已支持 `StageCode`
+
+`AdoS8ExceptionQueryDto.StageCode` 已存在,`S8ExceptionService` 已按:
+
+```csharp
+e.StageCode == q.StageCode
+```
+
+过滤。
+
+但 `S8ExceptionListPage.vue` 当前:
+
+- 未从路由读取 `stageCode`;
+- `loadList()` 未把 `stageCode` 传给 `s8ExceptionApi.list()`。
+
+因此灯态下钻仍需补前端路由接收。
+
+### 2.2 配置层
+
+2026-09-29 对租户 `838257186181189` 的只读取证:
+
+| 项目 | 结果 |
+|---|---:|
+| S8 场景 | 7 条启用 |
+| 监控指标 | 平台/租户合计 13 条,8 条启用 |
+| 监控规则 | 3 条 |
+| 启用且可运行规则 | 2 条 |
+| S1 规则 | 启用,最近 `SUCCESS` |
+| S2 规则 | 启用,最近 `SUCCESS` |
+| S4 规则 | 停用/自动暂停 |
+| S3/S5/S6/S7 | 没有启用规则 |
+
+这证明“页面为空是因为完全没有监控项”不成立。
+
+### 2.3 数据层
+
+同一租户只读取证:
+
+| 表/事实 | 结果 |
+|---|---:|
+| `ado_s8_alert_record` 当前有效行 | 0 |
+| `ado_s8_exception` 未删除异常 | 34 |
+| S1 `FOLLOW` 未关闭异常 | 2 |
+| S2 `SERIOUS` 未关闭异常 | 3 |
+| S4 `SERIOUS` 未关闭异常 | 29 |
+
+规则检测日志最近持续产生 `REFRESHED`,说明检测链在运行;首页为空是读错 Authority,不是检测链完全没有数据。
+
+### 2.4 可重跑基线 SQL
+
+执行前必须重新运行并保存结果。只允许 SELECT:
+
+```sql
+-- 1. 旧表是否仍为空
+SELECT COUNT(*) AS active_alert_count,
+       MAX(alert_time) AS latest_alert_time
+FROM ado_s8_alert_record
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND is_active = 1;
+```
+
+```sql
+-- 2. 当前异常按阶段和严重度分布
+SELECT stage_code,
+       severity,
+       COUNT(*) AS cnt,
+       MAX(COALESCE(last_detected_at, created_at)) AS latest_seen
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+GROUP BY stage_code, severity
+ORDER BY stage_code, severity;
+```
+
+```sql
+-- 3. 规则覆盖和运行态
+SELECT stage_code,
+       enabled,
+       COUNT(*) AS rule_count,
+       MAX(last_run_at) AS latest_run,
+       GROUP_CONCAT(DISTINCT IFNULL(last_status, 'NULL')) AS statuses,
+       MAX(paused_until) AS max_paused_until
+FROM ado_s8_watch_rule
+WHERE tenant_id = 838257186181189
+GROUP BY stage_code, enabled
+ORDER BY stage_code, enabled;
+```
+
+```sql
+-- 4. 最近 24 小时检测结果;REFRESHED 只能证明检测刷新,不能作为新增异常趋势
+SELECT rule_code,
+       detect_result,
+       COUNT(*) AS cnt,
+       MAX(detected_at) AS latest_detected
+FROM ado_s8_detection_log
+WHERE tenant_id = 838257186181189
+  AND detected_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
+GROUP BY rule_code, detect_result
+ORDER BY rule_code, detect_result;
+```
+
+---
+
+## 3. 冻结架构决策
+
+执行者不得在实现时重新设计以下口径。
+
+### 3.1 唯一 Authority
+
+| 能力 | Authority |
+|---|---|
+| 当前异常、异常标题、严重度、状态 | `ado_s8_exception` |
+| 模块监控覆盖、启停、最近运行结果 | `ado_s8_watch_rule` |
+| 规则每次检测审计 | `ado_s8_detection_log` |
+| 旧首页告警表 | `ado_s8_alert_record`,停止作为运行期 Authority |
+
+禁止给旧表补写入链路。旧表是否后续物理删除属于独立迁移任务,本任务不 DROP。
+
+### 3.2 三种时间语义必须分开
+
+1. **重点告警列表排序时间**
+   - `COALESCE(last_detected_at, created_at)`;
+   - 用于把最近仍被命中的当前异常排到前面。
+2. **今日/区间新增预警数**
+   - 只按 `created_at`;
+   - 表示该窗口内新建了多少异常。
+3. **每日异常趋势**
+   - 只按 `DATE(created_at)`;
+   - 表示每日新增异常数。
+
+禁止:
+
+- 用 `last_detected_at` 聚合趋势;
+- 把每轮 `REFRESHED` 算成一条新异常;
+- 用当前 34 条异常的最后刷新时间全部堆到今天。
+
+### 3.3 当前异常集合
+
+灯态和“重点告警列表”的当前异常定义固定为:
+
+```text
+is_deleted = 0
+AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+```
+
+说明:
+
+- `RESOLVED`、`PENDING_VERIFICATION` 仍未闭环,应继续属于当前异常;
+- `CLOSED` 已闭环;
+- `REJECTED` 不应继续作为当前有效异常;
+- `RECOVERED` 在部分现行服务中仍作为兼容状态使用,即使字典未展示,也必须排除。
+
+区间汇总和历史趋势不排除已关闭项,因为它们统计的是窗口内“新增异常”,不是当前未闭环存量。
+
+### 3.4 S8 tenant-only 作用域
+
+S8 当前终态是租户级规则和异常:
+
+- 租户来自认证上下文;
+- 不信任客户端 `tenantId`;
+- `factory_id=0` 是租户级兼容值;
+- 新接口不要再把页面传入的 `factoryId=1` 当作过滤 Authority。
+
+禁止为让数据出现而写:
+
+```sql
+factory_id IN (0, @factoryId)
+```
+
+并把它当成长期口径。应明确按 tenant-only Authority 查询。
+
+### 3.5 严重度映射
+
+S8 当前业务枚举只有:
+
+- `FOLLOW`:关注;
+- `SERIOUS`:严重。
+
+保持现有前端告警行契约:
+
+| S8 severity | API level | 前端颜色 |
+|---|---|---|
+| `SERIOUS` | `critical` | 红 |
+| `FOLLOW` | `medium` | 黄 |
+
+兼容历史值时必须调用或等价复用 `S8SeverityCode.Normalize`,不得另写一套互相矛盾的映射。
+
+---
+
+## 4. S1–S7 双维度灯态设计
+
+### 4.1 为什么不能只做一个红黄绿字段
+
+单一颜色无法区分:
+
+- 没有异常且监控正常;
+- 没有异常但根本没有监控;
+- 异常仍存在但规则已暂停;
+- 异常仍存在且监控结果已经过期。
+
+因此必须拆成:
+
+```text
+灯芯 businessState:业务异常状态
+外圈 monitorState:监控运行状态
+```
+
+### 4.2 灯芯 `businessState`
+
+固定枚举:
+
+```text
+CRITICAL
+WARNING
+NORMAL
+UNKNOWN
+```
+
+判定优先级:
+
+1. 当前异常中 `SERIOUS > 0` → `CRITICAL`
+2. 否则当前异常中 `FOLLOW > 0` → `WARNING`
+3. 否则 `monitorState == HEALTHY` → `NORMAL`
+4. 其它情况 → `UNKNOWN`
+
+对应展示:
+
+| businessState | 灯芯 | 语义 |
+|---|---|---|
+| `CRITICAL` | 红 | 有严重未关闭异常 |
+| `WARNING` | 黄 | 有关注未关闭异常,无严重异常 |
+| `NORMAL` | 绿 | 有有效监控且新鲜,当前无未关闭异常 |
+| `UNKNOWN` | 灰 | 不能证明正常 |
+
+硬规则:只有 `monitorState=HEALTHY` 且当前异常为 0 时才允许绿色。
+
+### 4.3 外圈 `monitorState`
+
+固定枚举:
+
+```text
+HEALTHY
+DEGRADED
+PAUSED
+UNMONITORED
+```
+
+规则级健康定义:
+
+```text
+runnable =
+  enabled = 1
+  AND (paused_until IS NULL OR paused_until <= now)
+
+freshWindowSeconds = MAX(poll_interval_seconds * 2, 600)
+
+healthyRule =
+  runnable
+  AND last_status = 'SUCCESS'
+  AND last_run_at IS NOT NULL
+  AND now - last_run_at <= freshWindowSeconds
+```
+
+模块级聚合:
+
+1. 该模块没有任何规则 → `UNMONITORED`
+2. 有规则,但没有 runnable 规则 → `PAUSED`
+3. 有 runnable 规则,但任一规则:
+   - `last_status='FAILED'`;
+   - `last_run_at IS NULL`;
+   - 超出 `freshWindowSeconds`;
+   → `DEGRADED`
+4. 所有 runnable 规则均 healthy → `HEALTHY`
+
+展示规范:
+
+| monitorState | 外圈/徽标 | 禁止误解 |
+|---|---|---|
+| `HEALTHY` | 实线正常外圈 | 正在正常检测 |
+| `DEGRADED` | 橙色虚线或时钟/警告徽标 | 检测失败或过期 |
+| `PAUSED` | 灰色虚线 + 暂停徽标 | 有规则但未运行 |
+| `UNMONITORED` | 无外圈或灰色空心外圈 | 未配置监控 |
+
+异常颜色不得被外圈覆盖。例如:
+
+```text
+S4 = 红色灯芯 + 暂停外圈
+```
+
+表示“严重异常仍在,但规则已经暂停”,不能显示灰灯掩盖 29 条严重异常。
+
+### 4.4 当前基线的预期灯态
+
+若执行时数据未变化,UAT 租户应为:
+
+| 模块 | 业务异常 | 监控态 | 预期 |
+|---|---|---|---|
+| S1 | 2 条 FOLLOW | HEALTHY | 黄灯芯 + 正常外圈 |
+| S2 | 3 条 SERIOUS | HEALTHY | 红灯芯 + 正常外圈 |
+| S3 | 0 | UNMONITORED | 灰灯,无监控 |
+| S4 | 29 条 SERIOUS | PAUSED | 红灯芯 + 暂停外圈 |
+| S5 | 0 | UNMONITORED | 灰灯,无监控 |
+| S6 | 0 | UNMONITORED | 灰灯,无监控 |
+| S7 | 0 | UNMONITORED | 灰灯,无监控 |
+
+数据可能随检测和处理变化。验收必须按同一时刻 SQL 结果计算预期,禁止把上表数字写死进测试或前端。
+
+### 4.5 Tooltip
+
+前端根据结构化字段生成,不由后端返回整段中文。
+
+示例:
+
+```text
+S1:关注异常 2 条;监控正常;最后检测 2026-09-29 23:41
+S4:严重异常 29 条;监控规则已暂停;最后成功检测 2026-09-23 14:57
+S5:未配置监控规则;当前状态未知
+```
+
+至少显示:
+
+- 模块码和模块名;
+- 严重/关注/当前异常总数;
+- 监控态;
+- 最近运行时间;
+- 规则数;
+- 暂停或降级原因码。
+
+---
+
+## 5. 后端接口设计
+
+### 5.1 新增统一查询服务
+
+新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs
+```
+
+职责:
+
+1. 统一当前异常 Authority;
+2. 统一活动状态集合;
+3. 统一 severity 映射;
+4. 统一基础查询文本过滤;
+5. 提供:
+   - 最近重点异常;
+   - 区间新增汇总;
+   - 每日新增趋势;
+   - S1–S7 灯态。
+
+禁止把三套 SQL 继续散落在 Controller 中独立演进。
+
+服务使用当前 DI 模式实现为 `ITransient`。可注入:
+
+- `ISqlSugarClient`;或
+- `SqlSugarRepository<AdoS8Exception>` + `SqlSugarRepository<AdoS8WatchRule>`。
+
+选择须与同目录服务风格一致。不得自行创建静态连接或第二套 DbContext。
+
+### 5.2 保留现有三个 API 契约
+
+#### `GET /api/AidopKanban/s8-alerts`
+
+响应字段保持:
+
+```json
+[
+  {
+    "time": "23:41",
+    "module": "S1",
+    "message": "异常标题",
+    "level": "medium",
+    "levelLabel": "关注"
+  }
+]
+```
+
+规则:
+
+- 数据源改为 `ado_s8_exception`;
+- 只返回当前异常;
+- 按 `COALESCE(last_detected_at, created_at) DESC, id DESC`;
+- `LIMIT 6`;
+- `module` 优先 `stage_code`,其次 `module_code`,最后 `S8`;
+- `message` 优先 `title`,空时回落 `description`,再回落“异常告警”;
+- `SERIOUS → critical/严重`;
+- `FOLLOW → medium/关注`。
+
+#### `GET /api/AidopKanban/s8-alert-summary`
+
+响应契约保持:
+
+```json
+{
+  "windowStart": "2026-09-30",
+  "windowEnd": "2026-09-30",
+  "isToday": true,
+  "total": 3,
+  "critical": 2
+}
+```
+
+规则:
+
+- 按 `created_at` 半开窗口 `[windowStart, windowEnd)`;
+- `total` = 该窗口新建异常数;
+- `critical` = 该窗口新建 `SERIOUS` 异常数;
+- 不带 `LIMIT`;
+- 不按当前状态过滤,避免异常关闭后历史新增数被改写。
+
+#### `GET /api/AidopKanban/s8-home-trend`
+
+响应契约保持:
+
+```json
+{
+  "title": "近 7 日异常趋势",
+  "points": [
+    { "date": "09/24", "count": 0 }
+  ]
+}
+```
+
+规则:
+
+- 按 `DATE(created_at)` 聚合;
+- 没有新增异常的日期补 0;
+- 默认最近 7 个服务器自然日;
+- 继续保留当前最大日期跨度保护;
+- 不按当前状态过滤;
+- 不使用 `last_detected_at`;
+- 不读取 `ado_s8_detection_log` 的 `REFRESHED` 次数。
+
+### 5.3 新增灯态接口
+
+新增:
+
+```http
+GET /api/AidopKanban/s8-module-lamps
+```
+
+不接受客户端 `tenantId`。租户从认证上下文读取。
+
+推荐响应:
+
+```json
+{
+  "asOf": "2026-09-30T00:10:00+08:00",
+  "items": [
+    {
+      "code": "S1",
+      "name": "S1 产销协同",
+      "businessState": "WARNING",
+      "monitorState": "HEALTHY",
+      "seriousCount": 0,
+      "followCount": 2,
+      "openCount": 2,
+      "configuredRuleCount": 1,
+      "enabledRuleCount": 1,
+      "runnableRuleCount": 1,
+      "latestRunAt": "2026-09-29T23:41:07+08:00",
+      "monitorReasonCode": "OK"
+    }
+  ]
+}
+```
+
+`items` 必须始终按 S1–S7 返回 7 条。没有异常/规则的模块也要返回结构化 `UNKNOWN + UNMONITORED`,前端不得自行补假状态。
+
+`monitorReasonCode` 固定建议:
+
+```text
+OK
+NO_RULE
+RULE_PAUSED
+RULE_DISABLED
+NEVER_RUN
+RUN_FAILED
+RUN_STALE
+```
+
+一个模块多规则时,按最差运行态返回原因;同时保留计数,便于 tooltip 解释。
+
+### 5.4 基础查询过滤
+
+原 `BuildS8AlertTextFilterSql()` 针对旧表的:
+
+- `message`
+- `metric_code`
+- `alert_code`
+
+必须替换为异常 Authority 字段:
+
+- `title`
+- `description`
+- `related_object_code`
+- `source_object_id`
+- `source_payload`
+- `exception_code`
+
+筛选语义:
+
+- `product`
+- `orderNo`
+- `productionLine`
+
+均在上述可追溯文本字段中匹配。
+
+参数必须参数化。禁止字符串拼接用户输入。
+
+若 `source_payload` 数据量导致性能问题,先通过真实 `EXPLAIN ANALYZE` 取证,再另立结构化投影/索引任务;本任务不得默默删掉筛选能力。
+
+---
+
+## 6. 前端设计
+
+### 6.1 API 与类型
+
+修改:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+```
+
+新增:
+
+- `S8ModuleLampItem`
+- `S8ModuleLampPayload`
+- `fetchS8ModuleLamps()`
+
+枚举用联合类型,不使用任意字符串:
+
+```ts
+type S8BusinessState = 'CRITICAL' | 'WARNING' | 'NORMAL' | 'UNKNOWN'
+type S8MonitorState = 'HEALTHY' | 'DEGRADED' | 'PAUSED' | 'UNMONITORED'
+```
+
+### 6.2 九宫格 S8
+
+修改:
+
+```text
+Web/src/views/dashboard/home.vue
+```
+
+要求:
+
+1. 删除硬编码 `s8ModuleStatus` 状态;
+2. 初始化为结构化“加载中/未知”,不得闪现绿色;
+3. `loadS8Data()` 同时加载:
+   - 告警列表;
+   - 趋势;
+   - 模块灯态;
+4. 灯芯颜色只由 `businessState` 决定;
+5. 外圈/徽标只由 `monitorState` 决定;
+6. 请求失败:
+   - 不显示绿色;
+   - 统一显示灰色 `UNKNOWN`;
+   - tooltip 为“监控状态加载失败”,不得写“正常”;
+7. 保留 S1–S7 七项顺序;
+8. 使用后端 `asOf/latestRunAt`,不要前端猜服务端当前时间。
+
+### 6.3 下钻
+
+灯点击后跳转:
+
+```text
+/aidop/s8/exceptions?stageCode=S1
+```
+
+修改:
+
+```text
+Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
+```
+
+要求:
+
+- `query` 增加 `stageCode`;
+- `onMounted` 和 `onActivated` 能读取路由 `stageCode`;
+- `loadList()` 传给后端;
+- reset 时清空;
+- 页面应有可见的阶段筛选状态,至少在查询条件摘要或筛选控件中展示,避免用户不知道为何只看到一个模块;
+- keep-alive 页面从 S1 灯切到 S2 灯时必须响应路由 query 变化,不得仍保留 S1 数据。
+
+不得新增 FUNC 编号:目标页已经登记为 FUNC-S8-008,详情页为 FUNC-S8-019。
+
+### 6.4 样式
+
+修改范围优先留在:
+
+```text
+Web/src/views/dashboard/home.vue
+```
+
+建议 class:
+
+```text
+s8-mod__dot--critical
+s8-mod__dot--warning
+s8-mod__dot--normal
+s8-mod__dot--unknown
+s8-mod--monitor-healthy
+s8-mod--monitor-degraded
+s8-mod--monitor-paused
+s8-mod--monitor-unmonitored
+```
+
+无障碍要求:
+
+- 不能只靠颜色;
+- tooltip/title 必须写出“严重/关注/正常/未监测/暂停/过期”;
+- 暂停和降级有形状或徽标差异;
+- 键盘可聚焦并可进入异常列表。
+
+---
+
+## 7. 精确文件清单
+
+### 7.1 固定修改
+
+后端:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs        新增
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs       修改
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs      修改
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8HomeAlertQueryServiceTests.cs   新增
+server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj                            提交时升 patch
+```
+
+前端:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/dashboard/home.vue
+Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
+Web/src/views/dashboard/home.s8-empty.spec.ts
+Web/src/views/aidop/kanban/dashboardSemantics.spec.ts
+Web/package.json                                                               提交时升 patch
+```
+
+### 7.2 条件修改
+
+只有在现有测试布局明显不适合时才新增:
+
+```text
+Web/src/views/dashboard/home.s8-module-lamps.spec.ts
+```
+
+若 Controller 构造函数注入 `S8HomeAlertQueryService`:
+
+- 全仓检索 `new AidopKanbanController(`;
+- 有手工构造点则同步改;
+- 无调用点则在交付报告写明全仓零匹配。
+
+### 7.3 明确不改
+
+- `AdoS8Exception` 表结构;
+- `AdoS8WatchRule` 表结构;
+- 历史成功迁移;
+- 规则启停数据;
+- 异常业务数据;
+- `ado_s8_alert_record` 数据;
+- S1–S7 KPI 计算;
+- S8 规则 Provider;
+- 菜单种子和 FUNC 编号。
+
+---
+
+## 8. 工作包
+
+### WP-A:Authority 收口
+
+1. 新建 `S8HomeAlertQueryService`。
+2. 将列表、汇总、趋势迁入服务。
+3. Controller 只负责:
+   - 认证租户解析;
+   - 查询参数转换;
+   - 返回 HTTP 结果。
+4. 删除或改写 `BuildS8AlertTextFilterSql()`,不得保留旧表字段残渣。
+5. 全仓检索 `ado_s8_alert_record`:
+   - 本任务三个端点不得再命中;
+   - 其它遗留读取点逐条报告,未经确认不顺手扩大范围。
+
+验收:
+
+```text
+s8-alerts        → 当前未关闭异常
+s8-alert-summary → created_at 窗口新增数
+s8-home-trend    → created_at 每日新增数
+```
+
+### WP-B:灯态聚合
+
+1. 服务一次读取租户的 S1–S7 当前异常;
+2. 一次读取该租户规则;
+3. 内存按 7 个模块聚合;
+4. 不允许每模块两次查询造成 N+1;
+5. 按 §4 固定优先级计算。
+
+必须使用单一 `now` 快照,避免 7 个模块判定跨秒漂移。
+
+### WP-C:前端双维度展示
+
+1. 接入新接口;
+2. 删除硬编码 `unknown` 数组;
+3. 实现灯芯和外圈;
+4. 请求失败 fail-closed;
+5. tooltip 使用结构化字段。
+
+### WP-D:下钻
+
+1. 点击灯跳 FUNC-S8-008;
+2. 路由带 `stageCode`;
+3. 异常列表页解析并透传;
+4. keep-alive 和路由切换正确刷新;
+5. 返回列表后筛选条件仍可解释。
+
+### WP-E:回归与真实数据验收
+
+1. 重跑基线 SQL;
+2. 用同一时刻数据计算预期灯态;
+3. 登录 UATAdminA 验证;
+4. 截图必须同时显示:
+   - S1 黄色;
+   - S2 红色;
+   - S4 红色且有暂停外圈;
+   - 无规则模块灰色;
+5. 点击 S1/S2/S4 分别验证列表过滤;
+6. 不得因数据之后变化而把具体数量写死为测试断言。
+
+---
+
+## 9. 测试要求
+
+### 9.1 后端服务单元测试
+
+新增 `S8HomeAlertQueryServiceTests.cs`,至少覆盖:
+
+1. `SERIOUS` 优先于 `FOLLOW`;
+2. 只有 FOLLOW → WARNING;
+3. 无异常 + 所有 runnable 规则新鲜成功 → NORMAL;
+4. 无规则 → UNKNOWN + UNMONITORED;
+5. 有规则但全停用/暂停 → PAUSED;
+6. 最近状态失败 → DEGRADED;
+7. 从未运行 → DEGRADED;
+8. `last_run_at` 超过 `max(2*poll, 600s)` → DEGRADED;
+9. S4 严重异常 + 规则暂停 → CRITICAL + PAUSED;
+10. `CLOSED/REJECTED/RECOVERED` 不计当前异常;
+11. 7 个模块总是完整、排序稳定;
+12. 同一 `now` 参与全部判定。
+
+### 9.2 后端契约测试
+
+更新 `S8DashboardSemanticsTests.cs`:
+
+- 三个端点不再包含 `FROM ado_s8_alert_record`;
+- 读取 `ado_s8_exception`;
+- summary/trend 使用 `created_at`;
+- trend 不使用 `last_detected_at`;
+- list 仍 `LIMIT 6`;
+- summary 不带 `LIMIT`;
+- 租户来自 `AidopTenantHelper`;
+- 灯态端点存在;
+- 无客户端 tenant 过滤入口;
+- 严重度契约仍返回 `critical/medium`。
+
+旧测试 `T23_LegacyAlertsEndpoint_StillReturnsRecentList` 应改名并改语义,禁止留下“旧端点不得改”的过期断言。
+
+### 9.3 前端测试
+
+至少覆盖:
+
+1. API 参数及类型;
+2. S1–S7 顺序;
+3. CRITICAL 红灯;
+4. WARNING 黄灯;
+5. HEALTHY 且 0 异常才绿;
+6. UNMONITORED 灰灯;
+7. CRITICAL + PAUSED 同时显示;
+8. 请求失败不显示绿;
+9. tooltip 含数量、监控态、最近时间;
+10. 点击生成 `/aidop/s8/exceptions?stageCode=Sx`;
+11. 异常列表读取并透传 `stageCode`;
+12. 从 S1 灯切 S2 灯会刷新;
+13. 旧 Demo 告警不会恢复;
+14. S8 真 0 行时仍显示“暂无异常告警”,请求失败显示“异常数据加载失败”。
+
+### 9.4 全量测试基线
+
+当前已知后端固定失败集合为 7 条;执行前重新确认:
+
+1. `S0DimContractTests.ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere`
+2. `S1RequirementExamineDwdContractTests.Table_HasNoS8SpecificColumns`
+3. `S1RequirementExamineDwdContractTests.CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration`
+4. `S1RequirementExamineDwdContractTests.BooleanColumn_FollowsRepoFlagConvention_NotBit`
+5. `S1RequirementExamineDwdContractTests.NewColumns_ExistInBothInlineDdlAndMigration`
+6. `S1RequirementExamineDwdContractTests.QuantityColumns_DocumentedAsStandardNames`
+7. `S6ProductionInstructionContractTests.Std_Schedule_HasNewNullableColumns`
+
+新增测试可以增加总数,但失败名字集合不得扩大。
+
+执行:
+
+```powershell
+dotnet build server\Admin.NET.Web.Entry\Admin.NET.Web.Entry.csproj -v q --nologo
+dotnet build server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj -v q --nologo
+dotnet test server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj `
+  --no-build --nologo --logger "trx;LogFileName=s8-home-lamps.trx"
+
+Set-Location Web
+npm run test:run
+npm run build
+```
+
+用 TRX 比较失败用例名。测试完成后删除本轮 `TestResults`。
+
+---
+
+## 10. 接口与 SQL 验收
+
+### 10.1 API 验收
+
+以 UATAdminA 登录态调用:
+
+```http
+GET /api/AidopKanban/s8-alerts
+GET /api/AidopKanban/s8-alert-summary
+GET /api/AidopKanban/s8-home-trend
+GET /api/AidopKanban/s8-module-lamps
+```
+
+检查:
+
+- 响应不包含其它租户;
+- 列表最多 6 条;
+- summary 今日窗口正确;
+- trend 连续补齐日期;
+- module lamps 恰好 7 条;
+- 无秘密、连接串、payload 全文泄漏。
+
+### 10.2 同时刻对账 SQL
+
+#### 当前异常灯芯
+
+```sql
+SELECT stage_code,
+       SUM(CASE WHEN severity = 'SERIOUS' THEN 1 ELSE 0 END) AS serious_count,
+       SUM(CASE WHEN severity = 'FOLLOW' THEN 1 ELSE 0 END) AS follow_count,
+       COUNT(*) AS open_count
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+GROUP BY stage_code
+ORDER BY stage_code;
+```
+
+#### 今日新增
+
+```sql
+SELECT COUNT(*) AS total,
+       SUM(CASE WHEN severity = 'SERIOUS' THEN 1 ELSE 0 END) AS critical
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND created_at >= CURDATE()
+  AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY);
+```
+
+#### 7 日趋势
+
+```sql
+SELECT DATE(created_at) AS biz_date,
+       COUNT(*) AS cnt
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
+  AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
+GROUP BY DATE(created_at)
+ORDER BY biz_date;
+```
+
+API 与 SQL 必须能解释性对账。趋势补 0 日由 API 完成。
+
+---
+
+## 11. 提交与版本
+
+建议拆成两个可独立回滚的提交,但必须在同一发布窗口部署:
+
+### 提交 1:后端 Authority + 灯态接口
+
+范围:
+
+- `S8HomeAlertQueryService.cs`
+- `AidopKanbanController.cs`
+- 后端测试
+- server 版本
+
+若基线仍为 `1.0.595`:
+
+```text
+server → 1.0.596
+```
+
+建议提交信息:
+
+```text
+fix(s8): 首页告警改读异常权威表并提供模块灯态
+```
+
+### 提交 2:前端双维度灯 + 下钻
+
+范围:
+
+- `kanbanData.ts`
+- `home.vue`
+- `S8ExceptionListPage.vue`
+- 前端测试
+- Web 版本和对应锁文件
+
+若基线仍为 `2.4.414`:
+
+```text
+Web → 2.4.415
+```
+
+建议提交信息:
+
+```text
+fix(s8): 九宫格展示异常灯芯与监控运行外圈
+```
+
+两个提交均须 push。若用户明确要求只 commit 不 push,才停止在本地。
+
+---
+
+## 12. 反向影响推演
+
+### 12.1 代码调用面
+
+**需同步改:**
+
+- `s8-alerts` 被九宫格首页使用;
+- `s8-alert-summary` 被独立 S8 看板使用;
+- `s8-home-trend` 被九宫格首页使用;
+- `HomeModule` 灯下钻影响 FUNC-S8-008 异常列表;
+- `AidopKanbanController` 构造函数若加服务,须全仓检索手工构造调用方。
+
+### 12.2 数据契约面
+
+**需同步改但保持兼容:**
+
+- 三个既有接口 JSON 字段不变;
+- Authority 从旧告警表切到异常主表;
+- 严重度由 `SERIOUS/FOLLOW` 映射到现有 `critical/medium`;
+- 新增灯态接口是增量契约;
+- 智慧诊断、ChatBI、导出当前未发现直接调用灯态接口;执行时须再次全仓检索。
+
+### 12.3 多数据源面
+
+**无直接影响:**
+
+- 本任务不直接读取 T8、165、自建单或标准 API;
+- 各来源先由现有规则 Provider 形成 S8 异常;
+- 页面只消费 S8 统一异常 Authority。
+
+若某来源没有配置规则,应显示 `UNMONITORED`,不得在展示层特判来源。
+
+### 12.4 多租户 / Domain 面
+
+**需同步核查:**
+
+- 只按认证租户读取;
+- S8 规则和异常遵循 tenant-only;
+- 不信任请求 tenantId;
+- 不把普通业务 `factoryId=1` 强套到 S8 `factory_id=0`;
+- `stage_code` 只允许 S1–S7。
+
+### 12.5 运行面
+
+**无迁移、无种子、无定时任务改动。**
+
+但需注意:
+
+- 页面状态随规则最近运行时间变化;
+- 后端或 Scheduler 停止后灯外圈应自动转 `DEGRADED`;
+- 重启后不得短暂闪绿;
+- 旧表仍保留但不再供这三个端点读取。
+
+### 12.6 验证面
+
+**需同步改:**
+
+- 过期的 legacy Authority 契约测试;
+- 服务级灯态矩阵;
+- 前端双状态组合;
+- 下钻路由;
+- UATAdminA 实际数据对账。
+
+---
+
+## 13. 风险与回滚
+
+### 13.1 主要风险
+
+1. 把 `last_detected_at` 当新增日期导致趋势膨胀。
+2. 只看异常、不看规则健康,把未监测模块显示绿色。
+3. 规则暂停后把仍存在的异常隐藏。
+4. 前端 keep-alive 不响应路由 query,点击不同灯仍显示旧模块。
+5. tenant-only 语义被旧 `factoryId=1` 过滤掉。
+6. 直接显示 `source_payload` 导致敏感业务报文泄漏。
+
+### 13.2 回滚
+
+- 前端可回滚到灰色 unknown,但不应回写假灯态;
+- 后端可暂时回滚接口实现,但禁止通过向旧表造数据回滚;
+- 新接口为增量接口,不涉及 DROP;
+- 不删除历史异常和规则;
+- 回滚后需明确页面重新失去 Authority,不能把空白称为“无异常”。
+
+---
+
+## 14. 总验收清单
+
+### Authority
+
+- [ ] 三个首页/S8 接口不再读取 `ado_s8_alert_record`
+- [ ] 当前异常读取 `ado_s8_exception`
+- [ ] 规则健康读取 `ado_s8_watch_rule`
+- [ ] 未新增旧表写入方
+
+### 时间语义
+
+- [ ] 列表按最后命中时间排序
+- [ ] summary 按 `created_at`
+- [ ] trend 按 `created_at`
+- [ ] `REFRESHED` 未被当成新增异常
+
+### 灯态
+
+- [ ] SERIOUS 红
+- [ ] FOLLOW 黄
+- [ ] 监控健康且 0 异常才绿
+- [ ] 无规则灰
+- [ ] 暂停有独立外圈/徽标
+- [ ] 失败/陈旧有 DEGRADED 外圈
+- [ ] 异常颜色不会被暂停状态覆盖
+- [ ] 七灯不硬编码业务状态
+
+### 下钻
+
+- [ ] 点击进入 FUNC-S8-008
+- [ ] 带 `stageCode`
+- [ ] 列表实际按 stage 过滤
+- [ ] keep-alive 路由切换刷新
+- [ ] 筛选条件对用户可见
+
+### 真实性
+
+- [ ] 未改异常业务数据
+- [ ] 未改规则启停数据
+- [ ] 未复制其它租户
+- [ ] 未回填旧告警表
+- [ ] 同时刻 API 与 SQL 可对账
+
+### 工程
+
+- [ ] 后端构建 0 error
+- [ ] 前端构建成功
+- [ ] 新增测试通过
+- [ ] 既有失败集合未扩大
+- [ ] server/Web 版本正确
+- [ ] commit + push 成功
+
+---
+
+## 15. 交付报告模板
+
+执行者最终必须按以下结构汇报:
+
+```text
+1. 提交与版本
+2. 实际改动文件
+3. Authority 收口结果
+4. 三个既有接口验收
+5. S1–S7 逐灯验收
+6. 下钻验收
+7. 同时刻 SQL 对账
+8. 测试结果与既有失败对比
+9. 反向影响推演
+   - 代码调用面:无影响 / 需同步改(文件)
+   - 数据契约面:无影响 / 需同步改(接口/表)
+   - 多数据源面:无影响 / 需同步改(来源)
+   - 多租户/Domain:无影响 / 需同步改
+   - 运行面:无影响 / 需同步改
+   - 验证面:无影响 / 需同步改
+10. 未覆盖模块及原因
+```
+
+不得只汇报“灯亮了”或“页面有数据”。必须同时解释:
+
+- 异常来自哪里;
+- 为什么是红/黄/绿/灰;
+- 监控是否正常;
+- 最近一次检测时间;
+- 点击后能否看到对应异常。
+
+---
+
+## 16. 给执行模型的启动指令
+
+将本段与本文件路径一并交给新的执行模型:
+
+```text
+请严格执行:
+doc/plan/S8九宫格异常权威数据与模块灯态根治执行任务书.md
+
+开始时:
+1. 读取 AGENTS.md 和所有 alwaysApply 规则。
+2. 核对 HEAD、server/Web 版本、完整 git status,不覆盖或提交他人改动。
+3. 重新运行任务书 §2.4 的只读 SQL,按代码层/配置层/数据层分别记录证据。
+4. 全仓检索三个既有接口和 AidopKanbanController 手工构造调用方。
+5. 按 §7 列精确改动文件、跨模块影响和不做事项,等待用户确认后再改。
+
+实现时严格遵守冻结决策:
+- ado_s8_exception 是异常 Authority;
+- ado_s8_watch_rule 是监控覆盖和运行态 Authority;
+- 不写 ado_s8_alert_record;
+- list 可按 last_detected_at 排序,但 summary/trend 必须按 created_at;
+- 绿色只允许“监控健康且当前异常为 0”;
+- 异常灯芯与监控外圈必须分开;
+- S4 即使规则暂停,只要严重异常未关闭仍保持红灯;
+- 点击灯进入 FUNC-S8-008,并按 stageCode 过滤;
+- 不造数、不改异常和规则业务数据。
+
+完成后:
+- 后端、前端分别测试和构建;
+- 用 TRX 对比固定失败集合;
+- 用同一时刻 SQL 对账 S1-S7 灯态;
+- 按 §11 升版本、分提交并 push;
+- 按 §15 模板交付。
+```
+

+ 659 - 0
doc/plan/九宫格S5-S9无数据根因修复执行任务书.md

@@ -0,0 +1,659 @@
+# 九宫格 S5–S9 无数据根因修复执行任务书
+
+> 适用范围:智慧运营九宫格看板(FUNC-S9-005,`/aidop/smart-ops/grid`)取数链路、MDP 贴源入站、T8 外部数据源连接
+> 目标读者:后端开发、前端开发、大语言模型执行代理、测试负责人
+> 编写日期:2026-09-28(同日自查修订一次,见 §0 的四条推翻记录)
+> 任务类型:根因修复 / 数据可见性治理 / 死代码清理
+> 基线提交:`85acd840f`(server 1.0.590 / Web 2.4.412)
+> 运行库:MySQL `123.60.180.165:3306/aidopdev`,UAT 租户 `838257186181189`,factory_id `1`
+
+---
+
+## 0. 执行者须知(先读这一节)
+
+本任务书的调查过程中**产生过四个错误结论,均已被实测推翻**。写在最前面,是为了防止执行者从别处(历史会话、旧文档、口头转述、本文档的早期版本)把它们再捡回来:
+
+| 曾经的错误结论 | 实际情况 | 推翻它的证据 |
+|---|---|---|
+| ❌「九宫格空白是因为 `GetHomeL1` 用了全局 `MAX(biz_date)`,把 S4–S9 筛掉了」 | `GetHomeL1` **不是**九宫格的数据源。九宫格走 `GetHomeGridGeneric`,该端点早已是布局驱动 + **每指标各自**取最新业务日,写法正确 | `HomeModuleKpiCard.vue:23` → `fetchHomeGrid` → `kanbanData.ts:1275` → `GET /api/AidopKanban/home-grid/{moduleCode}` → `AidopKanbanController.Generic.cs:64-141`;`fetchHomeL1` 全仓零调用点 |
+| ❌「九个模块都有 L1 数据,是查询把它们筛掉了」 | 九个模块都有 **行**,但 S5/S6/S7 的绝大多数行是 `result_status='NO_DATA'` 且 `metric_value IS NULL`。卡片一直在渲染,只是没有值 | 见 §1.3 的实测结果 |
+| ❌「S5 死在 `InventoryMdpSyncService`,S7 死在 `ShipTransNeutralProjection`,各一处」 | **S5 与 S7 死在同一处**(`InventoryMdpSyncService.cs:891`)。`ShipTransNeutralProjection` 位于其下游 `:876`,从未被执行到 | 见 §3.B.2 的完整调用栈 |
+| ❌「`db_extra_params` 的非法值应在源配置保存时校验拦截」 | 该字段**根本无法通过保存接口设置**,脏值来自已提交的迁移脚本 `1.0.178.sql:210`。"保存时校验"无处施力 | 全仓检索 `DbExtraParams` 仅两处命中(实体 + 消费点);`MdpSourceConfigService.Save:84` 的入参 DTO 无此字段 |
+
+**教训有两条**:
+
+1. **数了行数不等于有数据。** 本任务书里所有"有/没有"的断言都带 `result_status` 与 `metric_value` 的取值,执行者验收时必须照同样标准,不得只 `COUNT(*)`。
+2. **按语义猜调用关系必错。** 上表第三行就是"S5 是库存所以走库存服务、S7 是发运所以走发运投影"这种想当然的产物。**一律以实际堆栈/实际检索为准**,本任务书凡涉及调用关系处均已附堆栈或检索结果。
+
+另:本任务书遵循仓库既有约定,执行时必须一并满足:
+
+- 动手前先按 §7 列出改动清单并取得确认(`.cursor/rules/collaboration-scope.mdc`)
+- 交付前必须写「反向影响推演」小节(`.cursor/rules/impact-backtrace.mdc`),§6 给出了本轮的基线版本,执行者若扩大范围须自行补充
+- 能力断言必须分代码层/配置层/数据层三层取证(`.cursor/rules/capability-claims-evidence.mdc`)
+- 版本号按本次提交实际纳入的端递增(`.cursor/rules/version-bump-on-commit.mdc`),详见 §8
+- **新增的 `UpdateScripts/*.sql` 必须在 `Admin.NET.Web.Entry.csproj` 里登记**,否则脚本永远不会执行,详见 §8.2
+
+---
+
+## 1. 真实根因
+
+### 1.1 结论一句话
+
+九宫格 S5–S9 看起来没数据,是**三件独立的事**叠在一起:
+
+1. **S5 唯一算出真值的指标没有被登记**,因此不显示 —— 显示侧缺陷,本任务 A。
+2. **S5/S7 的重算跑不完**,被一条诊断日志语句中途杀死 —— 计算侧缺陷,本任务 B。
+3. **T8 外部源连不上**,源数据根本没进来,所以绝大多数指标只能写 `NO_DATA` —— 数据侧缺陷,本任务 C。
+
+B 和 C 才是格子真正空着的原因;A 是一个被冤枉的指标。
+
+另有两项伴生治理:
+
+4. **卡片值与展示日期口径不一致**,会把 8 月的数值标成 9 月的 —— 展示侧缺陷,本任务 E。
+5. **一条已死但会误导后人的取数链路**,内含编造的演示数字 —— 清理,本任务 D。
+
+### 1.2 取数链路(已核实,不要再改这条路)
+
+```
+Web/src/views/dashboard/home.vue
+  └─ HomeModuleKpiCard.vue:23  →  fetchHomeGrid()
+       └─ kanbanData.ts:1275   →  GET /api/AidopKanban/home-grid/{moduleCode}
+            └─ AidopKanbanController.Generic.cs:64  GetHomeGridGeneric()
+                 ├─ 卡片集:ado_smart_ops_layout_item(MetricLevel=1, IsEnabled=1,按 SortNo)
+                 ├─ 指标元数据:ado_smart_ops_kpi_master
+                 └─ 指标值:ado_s9_kpi_value_l1_day,每个 metric_code 各自取 MAX(biz_date)
+```
+
+值查询的相关子查询在 `AidopKanbanController.Generic.cs:100-107`,它按 `metric_code` 逐个取各自最新业务日,**不是**按模块、更不是全局。这个写法是正确的,本任务书**不修改**它。
+
+### 1.3 数据层实测基线(可重跑)
+
+```sql
+-- 九宫格 S5/S6/S7/S9 实际会拿到的值(与 Generic.cs:96-108 的 valSql 等价)
+SELECT v.module_code, v.metric_code, v.metric_value, v.result_status, v.biz_date
+FROM ado_s9_kpi_value_l1_day v
+WHERE v.tenant_id=838257186181189 AND v.factory_id=1
+  AND v.module_code IN ('S5','S6','S7','S9') AND v.is_deleted=0
+  AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l1_day v2
+                  WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
+                    AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code
+                    AND v2.is_deleted=0)
+ORDER BY v.metric_code;
+```
+
+2026-09-28 22:5x 实测结果:
+
+| 指标 | 值 | 状态 | biz_date | 九宫格上是否可见 |
+|---|---|---|---|---|
+| S5_L1_001 | NULL | NO_DATA | 2026-09-25 | 可见(显示无业务数据) |
+| S5_L1_002 | NULL | NO_DATA | 2026-09-25 | 可见(显示无业务数据) |
+| S5_L1_003 | NULL | NO_DATA | 2026-08-30 | 可见(显示无业务数据) |
+| **S5_L1_004** | **31.6653** | **OK** | 2026-08-30 | **不可见 ← 任务 A** |
+| S6_L1_001 / 002 / 003 | NULL | NO_DATA | 09-26 / 09-26 / 08-30 | 可见(显示无业务数据) |
+| S7_L1_001 | NULL | NO_DATA | 2026-09-26 | 可见(显示无业务数据) |
+| S7_L1_002 | 0.0000 | OK | 2026-09-26 | 可见 |
+| S7_L1_003 | NULL | NO_DATA | 2026-08-30 | 可见(显示无业务数据) |
+| S9_L1_001 | NULL | NO_DATA | 2026-08-30 | 可见(显示无业务数据) |
+| S9_L1_002 | 193.7930 | OK | 2026-09-23 | 可见 |
+| S9_L1_003 | 0.0000 | OK | 2026-09-22 | 可见 |
+| S9_L1_004 | 100.0000 | OK | 2026-08-17 | 可见 |
+| S9_L1_005 | 513.3946 | OK | 2026-09-24 | 可见 |
+
+**S9 其实有 4 个真值在显示**,与"S5–S9 全空"的直觉不符,执行者验收前请先自行重跑上表确认现状。
+
+---
+
+## 2. 任务 A:补齐 S5_L1_004 的租户登记
+
+### A.1 问题
+
+`S5_L1_004`「品类物料库存周转」是一个**完整的一等指标**,代码层全部就绪,唯独在 UAT 租户的**指标字典**与**布局表**里没有行,因此计算出来的真值(31.6653)在九宫格上不可见。
+
+三层取证:
+
+- **代码层:有。** 算法 `S5MdpSyncTransformService.cs:521 BuildS5L1004MaterialInventoryTurnoverAsync`,调用点 `:123-124`;语义依赖 `KpiSemanticDependency.cs:34`;智慧诊断证据 `SmartDiagnosisEvidenceRegistry.cs:1337-1339`;筛选白名单 `AidopKanbanController.SmartOpsFilter.cs:17`;守卫测试 `SmartDiagnosisEvidenceTests.cs:64`、`KpiResultStatusResolverTests.cs:21`。
+- **配置层:缺。** `SELECT TenantId, COUNT(*) FROM ado_smart_ops_kpi_master WHERE MetricCode='S5_L1_004' GROUP BY TenantId` → 只有 `0` 与 `1300000000001` 两行,**没有** `838257186181189`。布局表同样没有(见下方校验 SQL)。
+- **数据层:通。** 值已在 `ado_s9_kpi_value_l1_day` 落库,`metric_value=31.6653`、`result_status='OK'`。
+
+### A.2 根因
+
+`AidopKpiMasterSeed.cs` 的 S5 段(`:144-158`)只定义了 `S5_L1_001/002/003`,**从来没有 `S5_L1_004`**。历史上它是靠一次性迁移脚本手工补进两个租户的(`1.0.187.sql` 补 `1300000000001`),种子本身没修,所以此后每个新建租户都会缺这一项。
+
+布局表是由指标字典派生的(`AidopKpiMasterSeed.cs:1006-1040`),且 `:1008` 有 `if (layoutExists) continue;` —— 已有布局的租户不会被二次补齐。所以**改种子只能救新租户,存量租户必须靠迁移脚本**。
+
+### A.3 改动清单
+
+**A.3.1 修种子(治新租户)**
+
+文件:`server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs`
+
+在 `l1_s5_3`(`:156`)之后增加一条 `Ins(...)`,参数取自 `TenantId=0` 的模板行:
+
+| 字段 | 值 |
+|---|---|
+| MetricCode | `S5_L1_004` |
+| ModuleCode | `S5` |
+| MetricLevel | `1` |
+| MetricName | `品类物料库存周转` |
+| Unit | `天` |
+| Direction | `lower_is_better` |
+| YellowThreshold | `110` |
+| RedThreshold | `120` |
+| Formula | `D1 / D2 × 30` |
+
+SortNo:取一个**大于 S5_L1_003(当前 19)**的值即可。布局是按 `moduleCode` 分组生成的(`:1010` 的 `module.OrderBy(...)`),跨模块重号无影响,只需保证模块内 S5_L1_004 排在 003 之后。先读取邻近取值再定,不要盲填。
+
+> 注意:`TenantId=0` 模板行的 `SortNo` 是 `540`,与种子里 17/18/19 这套编号不是同一套体系,**不要直接抄 540**。
+
+**A.3.2 写迁移(治存量租户)**
+
+新建 `server/Admin.NET.Web.Entry/UpdateScripts/<新版本>.sql`,要求**幂等**,按以下顺序:
+
+1. 对每个"**已有 `S5_L1_004` 值行、但 `ado_smart_ops_kpi_master` 里没有该指标**"的租户,以 `TenantId=0` 的行为模板 `INSERT ... SELECT` 补齐字典行。用 `NOT EXISTS` 守卫,`TenantId`/`Id` 之外的业务字段原样复制。可参照既有写法 `UpdateScripts/1.0.187.sql`。
+2. 对每个"**字典已有、布局缺行**"的 (tenant, factory, module) 组合,向 `ado_smart_ops_layout_item` 插入一行:
+   - `RowId = 'S5-L1-S5_L1_004'`(与 `AidopKpiMasterSeed.cs:1016` 的 `$"{moduleCode}-L{kpi.MetricLevel}-{kpi.MetricCode}"` 完全一致,**不要自创格式**)
+   - `MetricLevel=1`、`MetricCode='S5_L1_004'`、`DisplayName='品类物料库存周转'`、`IsEnabled=1`、`ParentRowId=NULL`
+   - `PanelZone` 按 `ResolveDefaultPanelZone`(`AidopKpiMasterSeed.cs:1044`)对 S5/L1 的返回值填写,执行前先读该方法确认
+   - `SortNo` 与 A.3.1 保持一致
+3. 迁移**不得**限定 `tenant_id=838257186181189`。按"有值却无登记"的条件驱动,这样其它租户的同类缺口一并收口。
+
+> **排序规则陷阱**:`ado_smart_ops_layout_item.MetricCode` 与 `ado_s9_kpi_value_l1_day.metric_code` 的 collation 不一致,两表直接 JOIN 会报 `Illegal mix of collations`。控制器里是显式加 `COLLATE utf8mb4_general_ci` 绕过的(见 `AidopKanbanController.cs:101`),迁移脚本与验收 SQL 里凡跨这两表比较 `metric_code` 的地方都必须照做。`ado_smart_ops_kpi_master.MetricCode` 同理。
+
+**A.3.3 配套 verify 脚本**
+
+新建同名 `.verify.sql`,返回单个布尔:对每个有 `S5_L1_004` 值的租户,字典行与布局行都存在。
+
+### A.4 验收
+
+```sql
+-- ① 值有而布局无 的指标应为 0 行
+SELECT DISTINCT v.module_code, v.metric_code
+FROM ado_s9_kpi_value_l1_day v
+LEFT JOIN ado_smart_ops_layout_item l
+  ON l.TenantId=v.tenant_id AND l.FactoryId=v.factory_id
+ AND l.MetricCode COLLATE utf8mb4_general_ci = v.metric_code
+ AND l.MetricLevel=1 AND l.IsEnabled=1
+WHERE v.tenant_id=838257186181189 AND v.factory_id=1 AND v.is_deleted=0
+  AND l.Id IS NULL;
+```
+
+修复前返回 `S5 / S5_L1_004` 一行,修复后必须返回 0 行。
+
+页面侧:以 UATAdminA 登录,`/aidop/smart-ops/grid` 的 S5 卡片应从 3 项变为 4 项,新增「品类物料库存周转 31.6653 天」。
+
+---
+
+## 3. 任务 B:诊断日志语句杀掉 S5/S7 重算
+
+### B.1 问题
+
+S5 与 S7 的重算在**入站成功之后、KPI 转换阶段**抛异常中止,作业落 `FAILED`,`error_message = "KPI 计算失败(源数据已入站,看板保留原有数据)"`。
+
+实测异常(`AidopT8KpiManualRefreshService`,2026-09-28 18:15:32 与 18:20:50):
+
+```
+MySqlConnector.MySqlException (0x80004005): Row 103 was cut by GROUP_CONCAT()
+   at SqlSugar.MySqlProvider.ExecuteCommandAsync(String sql, SugarParameter[] parameters)
+```
+
+数据层佐证:`SELECT @@group_concat_max_len, @@sql_mode` → `1024`,`sql_mode` 含 `STRICT_TRANS_TABLES`。严格模式把 GROUP_CONCAT 的截断**警告**升级成**错误**。
+
+### B.2 根因(两个缺陷叠加,必须都修)
+
+**缺陷一:`LEFT(GROUP_CONCAT(...), 500)` 是无效防御。**
+作者的本意是"这个采样字段截到 500 字符就够",但 `GROUP_CONCAT` 先在内部按 1024 字节截断并抛警告,`LEFT` 根本没有机会介入。仓库里有两处同构写法:
+
+| # | 位置 | 现状 |
+|---|---|---|
+| 一 | `MaterialWarehouse/InventoryMdpSyncService.cs:896`(方法 `LogWorkOrderNotFoundAsync:885`) | **已引爆**,S5 与 S7 都死在这里 |
+| 二 | `DataPlatform/Wms/ShipTransNeutralProjection.cs:127` | **未引爆的同类地雷**,见下 |
+
+**S5 与 S7 是死在同一个点上**,不是各死一处。完整调用栈(2026-09-28 18:15:32 与 18:20:50 实测):
+
+```
+InventoryMdpSyncService.LogWorkOrderNotFoundAsync            :891
+  ← InventoryMdpSyncService.MaterializeInvTransStdAsync      :871
+  ← InventoryMdpSyncService.TransformTransStdFromStgAsync    :299
+  ← S5MdpSyncTransformService.RunFullAsync                   :110   ← S5 走这条
+  ← S7MdpSyncTransformService.RunFullAsync                   :100   ← S7 走这条
+  ← AidopT8KpiManualRefreshService.RunModuleRefreshAsync      :155 / :173
+```
+
+**第二处为什么至今没炸,以及为什么必须一起修**:`MaterializeInvTransStdAsync` 里 `LogWorkOrderNotFoundAsync` 在 `:871` 调用,而 `_ship.ProjectAsync`(即第二处所在)在 `:876`。`:871` 先抛异常,`:876` 从未被执行到。一旦修好第一处,执行流会**第一次**走到第二处,同类崩溃极可能立即出现。
+
+> 因此 B.4 的验收**必须包含"修完第一处后重跑并确认第二处不炸"**,不能修完一处就收工。
+
+本仓库其它地方**已经踩过这个坑并留了字条**,改动时请与它们保持一致的处理范式:
+
+- `Supply/S3MdpSyncTransformService.cs:880-881`:「去重用 ROW_NUMBER 而不是 GROUP_CONCAT+SUBSTRING_INDEX:…… `group_concat_max_len` 实测仅 1024,拼接必然截断」
+- `DataPlatform/S0Dim/S0DimSqlBuilder.cs:250`:「用 SUM 而非 GROUP_CONCAT/BIT_XOR ……不受 `group_concat_max_len` 截断」
+
+**缺陷二:诊断日志写在致命路径上。**
+上述两条语句写的都是 `mdp_source_gate_log`(`sample_keys` 列),是**纯诊断**记录。一条 gate log 写失败,把整条 S5/S7 业务管线干掉了。即使缺陷一修好,下次诊断写入因别的原因失败仍会再杀一次。
+
+同一个仓库里已有正确范式:`DataPlatform/Executors/MdpDbPullExecutor.cs:231-234` 的作用域探针明写「探针纯诊断用途,任何失败都不得影响主流程,按"无异常"处理」。
+
+### B.3 改动清单
+
+**B.3.1 采样在进聚合之前限条**
+
+两处 SQL 改为:先用子查询按 `ROW_NUMBER()` 取前 N 条去重键,再对这 N 条做 `GROUP_CONCAT`。要求:
+
+- N 的取值必须保证 `N × 单元素最大长度 < 1024`,使结果**不依赖任何服务器变量**。`work_ord` 一类编码按 32 字节估算,N 取 20 是安全的;`ShipTransNeutralProjection` 的元素是 `OrdNbr:OrdLine` 拼接,需按实际字段长度重估后再定 N,不要照抄 20。
+- 保留外层 `LEFT(..., 500)` 作为列宽兜底(`sample_keys` 的列定义约束),但注释要写明它**不是**防截断手段。
+- `row_count` 仍统计**全量**条数,不受采样限条影响 —— 采样是样例,计数是事实,两者不能一起被截。
+
+**明确否决的方案**:`SET SESSION group_concat_max_len = <更大值>`。理由:① 只是把悬崖往后挪,元素再多一样炸;② SqlSugar 走连接池,需要保证每条连接都设过,是脆弱的会话态依赖;③ 完全不解决缺陷二。
+
+**B.3.2 gate log 写入移出致命路径**
+
+把这两处 `mdp_source_gate_log` 的 `INSERT` 包进 try/catch,失败只记 `LogWarning`,不向上抛。注释须说明:gate log 是数据质量诊断记录,它的失败不得中断业务管线;参照 `MdpDbPullExecutor.cs:231-234`。
+
+> 边界:**只放行 gate log 的写入失败**。同一方法里其它语句(贴源写入、投影 upsert)的异常仍必须向上抛,不得顺手一起吞掉。
+
+**B.3.3 守卫测试**
+
+在 `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/` 下新增测试,至少覆盖:
+
+1. 两处源码中不再出现「`GROUP_CONCAT` 直接作用于未限条集合」的形态(可用源码文本断言,锚定到方法体而非接口声明 —— 本仓库有过因锚点匹配到接口声明而误判的先例)。
+2. gate log 的 `INSERT` 处于 try/catch 内。
+3. 采样限条的 N 值存在且小于安全上界。
+
+### B.4 验收
+
+代码层:上述守卫测试通过。
+
+数据层:以 UATAdminA 在 `/aidop/smart-ops/s5` 与 `/s7` 点「重算数据」,然后
+
+```sql
+SELECT id, module_code, trigger_type, status, error_message
+FROM ado_module_dashboard_rebuild_job
+WHERE requested_by IS NOT NULL AND submitted_at > <本次重算时刻>
+ORDER BY submitted_at DESC;
+```
+
+S5 与 S7 的 `status` 必须不再是 `FAILED`(允许 `SUCCESS`;若因任务 C 未完成而入站失败,可以是 `FAILED` 但 `error_message` 必须是入站类而**不是**「KPI 计算失败」)。
+
+后端日志中 `Row 103 was cut by GROUP_CONCAT()` 不得再出现 —— **注意要连着检查两遍**:第一遍确认 `InventoryMdpSyncService.cs:891` 不再抛;由于 `ShipTransNeutralProjection` 此前从未被执行到(见 B.2),第一遍跑通后必须**再跑一次并确认栈里没有 `ShipTransNeutralProjection.ProjectAsync`**,否则只是把崩溃点往后挪了 5 行。
+
+`mdp_source_gate_log` 应出现新行,`sample_keys` 非空且长度受控,`row_count` 与实际不匹配行数一致(不受采样限条影响)。
+
+---
+
+## 4. 任务 C:T8 外部源连接串被一条配置写坏
+
+### C.1 问题
+
+22:00 那批 `AUTO` 重算的入站阶段失败,`error_message = "源数据入站失败,看板保留原有数据"`。实测异常:
+
+```
+SqlSugar.SqlSugarException: Connection open error . 不支持的关键字:"configid"。
+   at SqlSugar.SqlServerProvider.get_Connection()
+   at Admin.NET.Plugin.AiDOP.DataPlatform.Executors.MdpDbPullExecutor.PullAsync(...) :line 101
+   at Admin.NET.Plugin.AiDOP.DataPlatform.T8BaseInboundMdpSyncService.RunInboundAsync(...) :line 91
+```
+
+### C.2 根因
+
+配置层:
+
+```sql
+SELECT source_code, db_extra_params, (db_password_enc IS NULL OR db_password_enc='') AS pwd_empty,
+       health_status, LEFT(health_msg,150) AS msg
+FROM mdp_source WHERE source_code IN ('T8_V5_SQLSERVER','DOPDEMORQ_SQLSERVER');
+```
+
+| source_code | db_extra_params | 口令为空 | 健康 |
+|---|---|---|---|
+| `T8_V5_SQLSERVER` | `ConfigId=t8_v5;TrustServerCertificate=true;Encrypt=false` | **是** | 失败 |
+| `DOPDEMORQ_SQLSERVER` | `NULL` | 否 | OK |
+
+`ConfigId` 是 **SqlSugar** 的概念,不是 SQL Server 连接串关键字。`MdpSourceScopeFactory.BuildConnectionString`(`:117-139`)把 `db_extra_params` **原样拼接**到连接串尾部(`:125` 组装 `extra`,`:129-130` 为 SqlServer 分支),于是 SqlClient 报「不支持的关键字」。
+
+附带问题:该 `extra` 里的 `TrustServerCertificate` 与 `Encrypt` 与 `:130` 已硬编码的同名参数**重复**。
+
+**脏值的来源是仓库里一个已提交的迁移脚本,不是有人手工改库**:
+
+```205:221:server/Admin.NET.Web.Entry/UpdateScripts/1.0.178.sql
+INSERT INTO `mdp_source` (
+  ... `db_password_enc`,`db_extra_params`, `remark`
+) VALUES (
+  ... NULL,'ConfigId=t8_v5;TrustServerCertificate=true;Encrypt=false',
+  'S5/S6/S7 KPI 数据贴源同步用;只读 SELECT;凭据见 CLAUDE.md「T8 源库连接信息」与 Database.json'
+)
+ON DUPLICATE KEY UPDATE
+  ... `db_extra_params`=VALUES(`db_extra_params`), ...
+```
+
+注意 `db_password_enc` 在这里是**显式写 NULL 的**,remark 里说凭据另见他处——但 `BuildConnectionString:122` 读的就是 `DbPasswordEnc`,没有任何其它来源。所以这个源从登记那天起就没有可用口令。
+
+**代码层缺陷(此处修正一个想当然的判断)**:`db_extra_params` **无法通过页面/接口设置**。源配置保存入口是 `MdpSourceConfigService.Save:84`(`[HttpPost("sources")]`),其入参 DTO 里没有这个字段——全仓检索 `DbExtraParams` 只有两处命中:实体定义 `Entity/DataPlatform/MdpSource.cs:54` 与消费点 `MdpSourceScopeFactory.cs:125`。
+
+因此"在保存时校验"这个方向**在本字段上无处施力**,不能作为根治手段。真正的缺陷是:这个只能由迁移脚本写入的字段,**在被拼进连接串时没有任何校验**,非法值要等到某次重算深处真正拉数时才炸,而且炸出来的用户可见信息(「源数据入站失败」)完全指不到「你那条源配置里有个非法关键字」。
+
+### C.3 改动清单
+
+**C.3.1 连接串组装处校验(代码侧,主修复)**
+
+在 `MdpSourceScopeFactory.BuildConnectionString`(`:117-139`)拼接 `extra` 之前,用 `System.Data.Common.DbConnectionStringBuilder` 解析 `db_extra_params`,逐 key 判定并**剔除**非法项:
+
+- SqlSugar 自有概念(`ConfigId` 等)—— 它们不属于连接串
+- 与本分支已硬编码的键重复的项(SqlServer 分支 `:130` 已写死 `Server`/`Database`/`User Id`/`Password`/`TrustServerCertificate`/`Encrypt`)
+
+剔除时**必须 `LogWarning`**,信息要能定位到源与键,形如「源 `T8_V5_SQLSERVER` 的 db_extra_params 含非法关键字 `ConfigId`,已忽略」。不得静默丢弃。
+
+本仓库已有 `DbConnectionStringBuilder` 的使用先例可参照:`MdpSourceConnectionImportService.cs:33`。
+
+> 这里选择"剔除并告警"而非"抛异常":抛异常会让一条历史脏配置直接阻断整条入站链路,而剔除后连接串是合法的、能继续工作,告警足以驱动人工收口。
+
+**C.3.2 健康检查前移暴露(代码侧)**
+
+`MdpSourceHealthCheckJob`(`Job/MdpSourceHealthCheckJob.cs:35`)在巡检时对同样的非法 key 给出**明确的 `health_msg`**,使这类问题在源管理页就能看见,而不必等某次重算深处报「源数据入站失败」。
+
+**C.3.3 清理存量配置(数据侧)**
+
+新迁移脚本把 `T8_V5_SQLSERVER` 的 `db_extra_params` 清为 `NULL`。幂等:仅当当前值包含 `ConfigId` 时才更新。
+
+**C.3.4 修正脏值的源头(否则新环境会重新中招)**
+
+脏值来自 `UpdateScripts/1.0.178.sql:205-221`,该脚本在存量环境已执行且 SHA256 已记账。执行者必须在两种做法里选一种,**并在交付说明里写明选了哪种及理由**:
+
+- **(推荐)** 保持 `1.0.178.sql` 原样不动,在其 `db_extra_params` 那行上方加注释指向本次的新迁移,说明该值已被后续脚本收口。理由:已执行脚本的内容变更会与 `sys_db_migration_log` 里记录的 SHA256 不一致,可能触发校验告警。
+- 直接改 `1.0.178.sql` 的 `VALUES` 为 `NULL`。仅当已确认 `AutoVersionUpdate` 不会因 SHA 不一致而报错或重跑时才可选此项——**必须先取证**,不得想当然。
+
+**C.3.5 口令(须由人工提供,不得写进代码或脚本)**
+
+`T8_V5_SQLSERVER.db_password_enc` 为空(由 `1.0.178.sql:210` 显式写入 NULL),必须由负责人通过**页面**补录。保存入口 `MdpSourceConfigService.Save:84` 的 `DbPassword` 字段是可写的(`:128`),所以页面补录这条路是通的。
+
+> **安全红线**:口令、`AccessSecret`、数据库密码一律不得写入代码、迁移脚本、任务书或提交。本条只能以"待人工操作"形式留在验收清单里。
+
+**C.3.6 另一条独立的凭据问题(不在本任务范围)**
+
+`UAT_SRC_DB`(MySQL)的健康信息是 `Access denied for user 'root'@'49.65.129.7' (using password: YES)`,这是**真的凭据错误**,与 C.1 的连接串缺陷无关,需要单独处置。记录在此避免被合并误判。
+
+### C.4 验收
+
+```sql
+SELECT source_code, health_status, LEFT(health_msg,150) AS msg, last_health_check
+FROM mdp_source WHERE source_code='T8_V5_SQLSERVER';
+```
+
+`health_status` 为成功态、`health_msg='OK'`。
+
+代码层:构造一个 `db_extra_params` 含 `ConfigId=x` 的源,走一次拉数,确认连接成功建立且日志中有指名该源与该键的 `LogWarning`。
+
+> 由于 `db_extra_params` 无法通过页面设置(见 C.2),本项验收只能用单元测试直接调用组装逻辑,或临时用 SQL 造一条测试源。**不要**去页面上找这个输入框,它不存在。
+
+---
+
+## 5. 任务 D:删除已死的 `home-l1` 取数链路(内含编造数据)
+
+### D.1 问题
+
+`GetHomeL1` 端点与其前端消费方**已全部失去调用点**,但代码仍在,且内含大量编造的演示数字。留着的风险不是它现在做错了什么,而是**后人会把它当成有效实现接回去**。
+
+死代码取证(全仓检索,`Web/src` 范围内零调用点):
+
+| 对象 | 位置 | 调用点 |
+|---|---|---|
+| `GetHomeL1` | `Controllers/AidopKanbanController.cs:91-121` | 仅下列已死前端 |
+| `fetchHomeL1` / `HomeL1Row` | `Web/src/views/aidop/api/kanbanData.ts:4,703-710` | **0** |
+| `loadHomeModuleMetrics` 等 | `Web/src/views/dashboard/data/homeModulesSync.ts` | **0** |
+| `loadHomeModuleMetrics` 等 | `Web/src/views/aidop/kanban/data/homeModulesSync.ts` | **0** |
+| `home-l1` 调用 | `Web/src/views/dashboard/data/s2Kpis.ts:93` | 该文件本身 **0** 调用点 |
+
+> `Web/src/views/aidop/kanban/data/operationModelSchema.ts` 里出现的 `'homeS5.matchOnTimeActualPct'` 一类**是字符串字面量**(`dataRef` 元数据),不是 ES import,不构成依赖。
+
+### D.2 为什么必须删而不是留着
+
+`homeModulesSync.ts` 里同时存在四种会直接违反「不得编造数据」要求的写法:
+
+1. **硬编码演示数字作为默认值**:`manufacturingEfficiency: 2.05`、`woOnTimeDeliveryPct: 75` 等,配合 `?? homeS1.reviewSatisfactionPct` 的兜底,接口无数据时页面显示的是假数。
+2. **编造趋势序列**:`homeS1.trendSatisfaction = [71.5, 72, 72.5, 73, 73.5, 74, 74.5, <真值>]`。
+3. **一个指标填进多个不同指标的槽位**:`homeS1.constraintMaterialKitPct = homeS1.reviewSatisfactionPct`、`homeS6.woOnTimeDeliveryPct = homeS6.firstPassYieldPct`。
+4. **按 moduleCode 收敛导致同模块多指标互相覆盖**:`Object.fromEntries(list.map((x) => [x.moduleCode, x]))`,一个模块只剩最后一条。
+
+### D.3 改动清单
+
+删除:
+
+- `Web/src/views/dashboard/data/homeModulesSync.ts`
+- `Web/src/views/aidop/kanban/data/homeModulesSync.ts`
+- `Web/src/views/dashboard/data/s2Kpis.ts`
+- `kanbanData.ts` 中的 `fetchHomeL1` 与 `HomeL1Row`
+
+`GetHomeL1`(后端端点)**先不删,改为标注弃用**并在方法注释里写明:前端零调用点、九宫格实际走 `home-grid/{moduleCode}`、该端点的全局 `MAX(biz_date)` 写法存在跨模块互相筛除的缺陷因而不可复用。
+
+> 理由:后端端点属于对外可见接口,删除前需确认没有外部消费者(第三方集成、ChatBI、导出、运维脚本)。执行者若能取证确认无外部调用,可在同一轮删除;取证不足则只标弃用,另开一项。
+
+删除前必须先跑一次全仓检索确认调用点仍为 0(本任务书的取证时间是 2026-09-28,期间可能有人新增引用)。
+
+### D.4 验收
+
+`npm run build` 通过;全仓检索 `home-l1`、`fetchHomeL1`、`homeModulesSync` 在 `Web/src` 下零匹配(除保留的后端注释外)。
+
+---
+
+## 5.5 任务 E:卡片按各自业务日标注「数据截至」
+
+> 文档里排在 D 之后,**执行时排在 A 之后、B 之前**,原因见 §7。
+
+### E.1 问题
+
+九宫格的值是**每个指标各自**取最新业务日的(`AidopKanbanController.Generic.cs:100-107`),但返回给前端的 `bizDate` 只有**模块级**一个(`:166`,来自 `ResolveMaxBizDateGenericAsync` 的模块内 MAX)。两者口径不一致。
+
+实测后果(数据来自 §1.3):S5 的模块级 `bizDate` 是 `2026-09-25`,而 S5_L1_004 的值实际来自 `2026-08-30`;S9 的模块级是 `2026-09-24`,而 S9_L1_004 的值来自 `2026-08-17`。**把 8 月 17 日的数值标成"数据截至 9 月 24 日",比不标日期更有害。**
+
+这直接触碰本项目的硬要求:真实数值可以显示,但不得让人误以为是当前值。
+
+### E.2 改动清单
+
+**E.2.1 后端逐卡返回业务日**
+
+`AidopKanbanController.Generic.cs`:
+
+1. 值查询 `valSql`(`:96-108`)的 SELECT 增加 `v.biz_date AS BizDate`。
+2. 承载 DTO(`S4ValRow`)增加可空 `BizDate`。**注意该 DTO 与 `GetS4HomeGrid`(`AidopKanbanController.S4.cs:244-249`)共用**,S4 的 valSql 不选这一列,须确认留空不会影响 S4 现有行为,或为 S4 一并补上。
+3. `items` 的匿名对象(`:144-159`)增加 `bizDate`,取该指标自己的值;无值时为 `null`。
+4. 模块级 `bizDate`(`:166`)**保留**,语义改为"本模块最新的那一天",不再被当作每张卡的日期。
+
+> `filteredBundle`(`:119-138`,带业务维度筛选时的旁路取值)也要给出对应的业务日,否则一加筛选条件日期就失真。若该旁路暂无法给出日期,须显式返回 `null` 而不是回落到模块级日期。
+
+**E.2.2 前端按卡展示**
+
+`Web/src/views/dashboard/components/HomeModuleKpiCard.vue`:每张卡在数值下方标注「数据截至 {bizDate}」。超过阈值(建议 7 天,需确认)的卡片做弱化处理(变灰/降低对比度),并在悬浮提示里说明数据陈旧。
+
+`resultStatus='NO_DATA'` 的卡片继续显示「无业务数据」,**不显示**日期标注 —— 没有值就没有"截至"可言。
+
+### E.3 验收
+
+`GET /api/AidopKanban/home-grid/S9` 返回的 `items` 中,`S9_L1_004` 的 `bizDate` 为 `2026-08-17`,`S9_L1_005` 为 `2026-09-24`,两者不同。页面上两张卡显示各自的日期,`S9_L1_004` 呈弱化态。
+
+---
+
+## 6. 反向影响推演(基线)
+
+执行者若扩大改动范围,必须在此基础上补充。
+
+### 任务 A(补 S5_L1_004 登记)
+
+| 面 | 结论 |
+|---|---|
+| 代码调用面 | **无影响**。只增数据行,不改 `GetHomeGridGeneric` / `GetOperationL1Generic` 逻辑。`AidopKpiMasterSeed` 的新增 `Ins` 仅在新租户初始化时执行(`:1008` 的 `layoutExists` 守卫保证不改存量) |
+| 数据契约面 | **需同步改**。`ado_smart_ops_kpi_master` 与 `ado_smart_ops_layout_item` 各增一行。`ado_s9_kpi_value_l1_day` 不动 |
+| 多数据源面 | **无影响**。S5_L1_004 的计算链路(T8 TVF `Rep_总账_存货_V3` → `dwd_t8_material_inventory_turnover` → CONFIG_SQL)不变 |
+| 多租户 / Domain 面 | **需注意**。迁移必须按"有值却无登记"驱动,**不得**写死 `tenant_id=838257186181189`;`TenantId=0` 是模板行,不得修改或删除 |
+| 运行面 | **需同步改**。新增迁移脚本 + verify,且必须在 `.csproj` 登记(§8.2) |
+| 验证面 | **需补**。建议加守卫:`AidopKpiMasterSeed` 中 S5 的 L1 指标数与 `ado_smart_ops_kpi_master` 的模块契约一致 |
+
+### 任务 B(GROUP_CONCAT + gate log)
+
+| 面 | 结论 |
+|---|---|
+| 代码调用面 | **需同步改**。`InventoryMdpSyncService.cs:891-908`(已引爆,S5/S7 共用)与 `ShipTransNeutralProjection.cs:121-139`(未引爆,位于前者下游 `:876`)两处。改前须检索是否还有第三处同构写法。`LogWorkOrderNotFoundAsync` 由 `MaterializeInvTransStdAsync:871` 唯一调用,后者经 `TransformTransStdFromStgAsync:299` 被 S5 与 S7 两条重算链共用 —— 改这一处会同时影响两个模块 |
+| 数据契约面 | **需注意**。`mdp_source_gate_log.sample_keys` 的语义由"截断的全量拼接"变为"限条采样";`row_count` 仍为全量。若有下游读 `sample_keys` 并假设它是完整集合,须一并核查 |
+| 多数据源面 | **需核查**。`ShipTransNeutralProjection` 的 gate log 写死 `source_system='DOPDEMORQ_SQLSERVER'`;`InventoryMdpSyncService.LogWorkOrderNotFoundAsync`(`:885`,调用点 `:871`)开头即按 `DOPDEMORQ_SQLSERVER` 提前返回。T8 与 165 两条来源的可得性差异须确认 |
+| 多租户 / Domain 面 | **无影响**。两处语句均已带 `tenant_id=@tid` |
+| 运行面 | **无影响**。纯代码改动,无 DDL、无种子、无定时任务变更 |
+| 验证面 | **需补**。见 B.3.3 |
+
+### 任务 C(源连接串)
+
+| 面 | 结论 |
+|---|---|
+| 代码调用面 | **需同步改**。`MdpSourceScopeFactory.BuildConnectionString` 被所有 EXTERNAL 源共用(入口 `GetScopeAsync:21`,被 `MdpDbPullExecutor`、`S0DimSameDbStagingLoader`、`MdpExcelImportService`、`MdpHotWatchService` 等多处使用),加过滤会影响 MySQL / PostgreSQL / Oracle 分支,须确认过滤规则不会误杀这些库的合法参数。**过滤必须按 `MapDbType` 分支各自的硬编码键集合来定,不能用一份全局黑名单** |
+| 数据契约面 | **无影响**。只改 `mdp_source.db_extra_params` 的取值,不改表结构。但须注意脏值源头是已提交脚本 `1.0.178.sql`,见 C.3.4 |
+| 多数据源面 | **需核查**。全表扫一遍 `db_extra_params`,确认除 `T8_V5_SQLSERVER` 外没有其它源也藏着非法关键字 |
+| 多租户 / Domain 面 | **需注意**。`T8_V5_SQLSERVER` 的 `tenant_id=0`(全局源),`UAT_SRC_DB` 的 `tenant_id=838257186181189`(租户源)。清理脚本按 `source_code` 定位,不要按租户 |
+| 运行面 | **需同步改**。迁移脚本 + 健康检查会在下次巡检时重跑 |
+| 验证面 | **需补**。建议加守卫:`db_extra_params` 不得含 `ConfigId` |
+
+### 任务 E(逐卡业务日)
+
+| 面 | 结论 |
+|---|---|
+| 代码调用面 | **需同步改**。`S4ValRow` 被 `GetHomeGridGeneric` 与 `GetS4HomeGrid` 共用,加字段须确认 S4 路径不受影响;`HomeModuleKpiCard.vue` 被九宫格全部 8 个非 S8 模块复用,改一处即改全部 |
+| 数据契约面 | **需同步改**。`home-grid/{moduleCode}` 的 `items` 元素新增 `bizDate` 字段(增量、向后兼容)。模块级 `bizDate` 语义收窄,须在代码注释中写明 |
+| 多数据源面 | **无影响**。不触碰取数来源 |
+| 多租户 / Domain 面 | **无影响**。沿用既有 `tenant_id` / `factory_id` 谓词 |
+| 运行面 | **无影响**。无 DDL、无种子、无定时任务变更 |
+| 验证面 | **需补**。建议加守卫:`items` 的 `bizDate` 不得回落到模块级 `bizDate`(防止后人"图省事"把两者写成同一个值) |
+
+### 任务 D(删死代码)
+
+| 面 | 结论 |
+|---|---|
+| 代码调用面 | **需先取证**。删除前重跑全仓检索;后端 `GetHomeL1` 的外部消费者取证不足时只标弃用 |
+| 数据契约面 | **无影响** |
+| 多数据源面 | **无影响** |
+| 多租户 / Domain 面 | **无影响** |
+| 运行面 | **无影响** |
+| 验证面 | **无需新增**,`npm run build` 与既有测试即可 |
+
+---
+
+## 7. 执行顺序与范围边界
+
+建议顺序:**A → E → B → C → D**。A 立刻产生可见效果且风险最低;E 紧随其后,因为 A 会让一个 8 月 30 日的值出现在九宫格上,没有日期标注就构成误导;B 让 S5/S7 的重算能跑完;C 需要人工补口令,会卡住;D 是清理,放最后避免与前四项的检索互相干扰。
+
+> A 与 E 存在**顺序依赖**:单独做 A 会短暂引入一张无日期标注的陈旧卡片。若不能连续完成,应先做 E 再做 A。
+
+**本轮明确不做的事**(越界前须另行确认):
+
+- 不改 `GetHomeGridGeneric` / `GetOperationL1Generic` 的取数逻辑 —— 它们是对的。
+- 不改 `ado_s9_kpi_value_l1_day` 的任何已有数据。
+- 不改 KPI 的业务公式、口径、维度配置。
+- 不动 `ResolveMaxBizDateGenericAsync`(`AidopKanbanController.Generic.cs:277-293`)里可疑的工厂过滤 `factory_id=0 OR factory_id=1 OR factory_id=@f` 与 `factory_id<>@t`。**这是一个已知的遗留疑点**(工厂号与租户号在比较),与本任务书的 `factoryId=1` vs 雪花 `factory_id` 是同一件事,应另开任务处置。
+- 不注册 `UpdateScripts/` 下 11 个历史未登记脚本(`1.0.208`、`1.0.212`、`1.0.257`–`1.0.264`、`1.0.463`)。它们从未执行过,注册即等于对生产库执行 11 个未经审阅的迁移,其中含一个 `BEFORE UPDATE` 触发器。基线与守卫见 `Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MigrationScriptRegistrationTests.cs`。
+
+---
+
+## 8. 版本号、迁移登记与提交
+
+### 8.1 版本号
+
+按本次提交**实际纳入的文件**决定,不按会话历史:
+
+- 含 `server/` 下代码或配置 → `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>` **三处同号** patch +1(当前基线 `1.0.590`)。
+- 含 `Web/` 下代码或配置 → `Web/package.json` 的 `version` patch +1(当前基线 `2.4.412`)。
+- 前后端都改 → 两边都递增。
+- 纯文档 → 都不递增。
+
+任务 A/B/C 是后端,任务 D/E 是前后端都碰。
+
+> **易踩的坑**:用文本替换升版本号时,`.csproj` 里的 `<None Update="UpdateScripts\1.0.XXX.sql">` 会被一起替换掉,导致脚本登记指向不存在的文件。升号后务必回读该文件确认这两类出现位置。
+
+### 8.2 迁移脚本必须登记(否则永远不执行)
+
+`AutoVersionUpdate.LoadMigrationScripts()` 扫描的是**输出目录** `AppContext.BaseDirectory/UpdateScripts`,因此每个新脚本都必须在 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 里单独登记:
+
+```xml
+<None Update="UpdateScripts\1.0.XXX.sql">
+  <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+</None>
+<None Update="UpdateScripts\1.0.XXX.verify.sql">
+  <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+</None>
+```
+
+**漏了这一步的表现极具迷惑性**:表和列可能仍然存在(SqlSugar CodeFirst 会按实体建),看起来像迁移成功了,但脚本里的数据变更一条都没执行。判别方法是查 `sys_db_migration_log` 里有没有该版本号。`MigrationScriptRegistrationTests` 会卡住这个疏漏。
+
+### 8.3 构建与测试命令(实测可用,Windows / PowerShell)
+
+```powershell
+# 后端构建(改 server/ 后必跑)
+dotnet build server\Admin.NET.Web.Entry\Admin.NET.Web.Entry.csproj -v q --nologo
+
+# 测试工程构建 + 全量测试(用 trx 取失败用例名;控制台中文输出会乱码,不要靠肉眼读)
+dotnet build server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj -v q --nologo
+dotnet test  server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj `
+  --no-build --nologo --logger "trx;LogFileName=r.trx"
+$x=[xml](Get-Content server\Plugins\Admin.NET.Plugin.AiDOP.Tests\TestResults\r.trx)
+"TOTAL=$($x.TestRun.ResultSummary.Counters.total) FAILED=$($x.TestRun.ResultSummary.Counters.failed)"
+$x.TestRun.Results.UnitTestResult | Where-Object { $_.outcome -eq 'Failed' } | ForEach-Object { $_.testName }
+
+# 前端构建(改 Web/ 后必跑)
+cd Web; npm run build
+```
+
+已知坑:
+
+- 构建可能因输出 DLL 被正在运行的 `Admin.NET.Web.Entry` 进程占用而报 MSB3027/MSB3021。这**不是编译错误**,先 `Stop-Process` 该进程再重试。
+- 测试完成后删除 `TestResults` 目录,不要提交。
+- 源码文本类守卫测试的锚点要落在**实现**上(如 `public async Task<int> Xxx`),不能只用签名文本——接口声明与实现签名相同,会误匹配到空方法体。本仓库已因此误判过一次。
+
+### 8.4 提交
+
+本仓库「提交」= **commit + push** 到跟踪远端。收尾必须汇报远端可达的 commit 哈希。
+
+不得纳入提交:口令与任何凭据、构建产物(`bin`/`obj`)、临时日志(`_tmp_*.log`、`frontend-dev.log`)、Word 锁文件(`~$*.docx`)。
+
+---
+
+## 9. 总验收清单
+
+执行完成后逐条打勾,每条都要附**可重跑的证据**(SQL 及其返回值,或 `文件:行`):
+
+- [ ] **A-1** `AidopKpiMasterSeed.cs` 的 S5 段含 `S5_L1_004`
+- [ ] **A-2** §2.4 的"值有而布局无"查询返回 0 行
+- [ ] **A-3** 迁移脚本与 verify 已创建,且已在 `.csproj` 登记
+- [ ] **A-4** `sys_db_migration_log` 中该版本号 `status='Success'`
+- [ ] **A-5** 九宫格 S5 卡片为 4 项,含「品类物料库存周转 31.6653 天」
+- [ ] **B-1** 两处 `GROUP_CONCAT` 已改为进聚合前限条,注释说明 `LEFT()` 不是防截断手段
+- [ ] **B-2** 两处 gate log `INSERT` 已在 try/catch 内,失败只记 warning
+- [ ] **B-3** 新增守卫测试通过
+- [ ] **B-4** S5/S7 手工重算不再以「KPI 计算失败」结束
+- [ ] **B-5** 日志中不再出现 `Row 103 was cut by GROUP_CONCAT()`
+- [ ] **C-1** `BuildConnectionString` 剔除非法 key + `LogWarning`(指名源与键),且不误杀 MySQL/PG/Oracle 合法参数
+- [ ] **C-2** `MdpSourceHealthCheckJob` 对非法 key 给出可定位的 `health_msg`
+- [ ] **C-3** `T8_V5_SQLSERVER.db_extra_params` 已清理,且 `1.0.178.sql` 的源头问题已按 C.3.4 选定方案处置(交付说明写明选了哪种及理由)
+- [ ] **C-4** (**人工**)`T8_V5_SQLSERVER` 口令已通过页面补录 —— 不得写进任何文件
+- [ ] **C-5** `mdp_source` 全表复查:无其它源藏有非法关键字
+- [ ] **C-6** `T8_V5_SQLSERVER` 健康检查 `OK`
+- [ ] **E-1** `items` 每个元素带各自的 `bizDate`,与模块级 `bizDate` 不相等的情形能被复现(S9_L1_004=2026-08-17 vs 模块级 2026-09-24)
+- [ ] **E-2** 卡片显示「数据截至 X」,陈旧卡片弱化;`NO_DATA` 卡片不显示日期
+- [ ] **E-3** 带业务维度筛选时日期不失真(或显式为 null)
+- [ ] **D-1** 四个死文件/导出已删除,`npm run build` 通过
+- [ ] **D-2** `GetHomeL1` 已标弃用(或取证后删除)
+- [ ] **D-3** `Web/src` 下 `home-l1` / `fetchHomeL1` / `homeModulesSync` 零匹配
+- [ ] **全局-1** 后端与测试工程 0 编译错误
+- [ ] **全局-2** 全量测试失败数 **等于**改动前基线 **7**,且失败用例名逐一对齐。总数会因本任务新增守卫而大于基线 3213,这是预期的;**只比对失败集合,不比对总数**。基线 7 个失败为:
+  - `S0DimContractTests.ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere`
+  - `S1RequirementExamineDwdContractTests` × 5(`Table_HasNoS8SpecificColumns`、`CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration`、`BooleanColumn_FollowsRepoFlagConvention_NotBit`、`NewColumns_ExistInBothInlineDdlAndMigration`、`QuantityColumns_DocumentedAsStandardNames`)
+  - `S6ProductionInstructionContractTests.Std_Schedule_HasNewNullableColumns`
+- [ ] **全局-3** 版本号按 §8.1 递增,且 `.csproj` 的脚本登记未被文本替换误伤
+- [ ] **全局-4** 已 push,并汇报远端可达的 commit 哈希
+- [ ] **全局-5** 交付说明中包含「反向影响推演」小节,结论只用「无影响 / 需同步改(列文件)/ 需你决策(列选项)」三种表述
+
+---
+
+## 10. 附:本任务书未覆盖但已知的待决事项
+
+以下问题在调查中浮现,**不属于本任务范围**,各自需要单独决策,记录于此以免遗失:
+
+1. `ResolveMaxBizDateGenericAsync` 的工厂过滤疑点(§7)。
+2. `UAT_SRC_DB` 的 `Access denied` 凭据问题(§4.C.3.5)。
+3. `UpdateScripts/` 下 11 个从未执行的历史脚本(§7)。
+4. `dwd_supplier_delivery` 缺 `shipment_no` 列:2026-09-28 18:14:50 日志中有 `Unknown column 'shipment_no' in 'field list'`,当次未致命,属契约与实表不一致。
+5. S6 三个 L1 指标全部 `NO_DATA`,且 `S6_L1_003` 的最新业务日停在 2026-08-30。任务 B/C 完成后若仍为 `NO_DATA`,需单独排查 S6 的源数据可得性。
+6. S8 的两个 L1 指标最新业务日停在 2026-08-31,需单独排查。

+ 1222 - 0
doc/plan/九宫格最后真实值与数据新鲜度根治执行任务书.md

@@ -0,0 +1,1222 @@
+# 九宫格最后真实值、数据新鲜度与源数据缺口根治执行任务书
+
+> 适用页面:九宫格智慧运营看板(FUNC-S9-005,`/aidop/smart-ops/grid`)
+> 目标读者:可独立执行代码、迁移、测试和部署验证的开发人员或大语言模型代理
+> 编写日期:2026-09-29
+> 基线提交:`f613da8bc508d10e146ebc51bc30be580d27b11a`
+> 基线版本:server `1.0.591` / Web `2.4.413`
+> UAT 作用域:tenant `838257186181189`、factory `1`
+> 任务类型:跨模块公共取数契约修复 / 数据新鲜度治理 / 源数据链路根治 / 安全治理
+
+---
+
+## 0. 执行者须知
+
+### 0.1 目标
+
+本任务的最终目标不是“把空格填上数字”,而是在**不编造、不跨租户、不越过用户筛选范围**的前提下,让九宫格同时表达:
+
+1. 最近一次真实可用的指标值;
+2. 该值对应的业务日期;
+3. 最新一次计算是否仍有业务数据;
+4. 该值是否已经超过指标自身的新鲜度 SLA;
+5. 从未产生过真实值时,明确显示「无业务数据」;
+6. 当前确实没有异常时,S8 明确显示「暂无异常告警」,而不是空白。
+
+同时修复导致当前值长期无法更新的三个根因:
+
+- 本地员工状态 `AC/DC` 未映射到中立状态;
+- T8 口令仍以错误的仓库明文/数据库复制思路处理;
+- 指定租户的 S5/S7 重算入口遗漏库存物化互斥锁,和半小时同步并发时产生死锁。
+
+### 0.2 四条禁止
+
+1. **禁止造数**:不得直接 UPDATE `ado_s9_kpi_value_l1_day.metric_value`,不得复制其它租户指标值,不得以前端常量补空。
+2. **禁止隐藏最新失败/无数据状态**:显示最后真实值时,必须同时暴露最新计算状态,不能把旧值冒充当前值。
+3. **禁止越过查询范围回退**:用户显式传 `dateStart/dateEnd` 时,只能在该区间内寻找可用值。
+4. **禁止提交凭据**:数据库口令、AccessSecret、连接串中的密码不得写入代码、迁移、任务书、测试快照、日志或 Git。
+
+### 0.3 本任务推翻的旧结论
+
+| 旧结论 | 正确结论 |
+|---|---|
+| “每指标取 `MAX(biz_date)` 已经完全正确” | 只解决了模块间日期不一致;当最新一行是 `NO_DATA + NULL` 时,它仍会遮住更早的真实值 |
+| “按卡返回 `bizDate` + 前端固定 7 天变灰,就是完整的截至日期方案” | 不完整。`HomeModuleKpiCard.vue` 会先因 `resultStatus='NO_DATA'` 隐藏数值;固定 7 天还会把月度指标长期误判为陈旧 |
+| “三个人效指标都只差 `employment_status`” | S5/S7 的仓储岗位有 5 人但状态全是 `UNKNOWN`;S6 还缺 `PRODUCTION` 岗位人员,不能把空岗位或仓储人员硬映射成生产人员 |
+| “S6 L1 无数据是因为 `mdp_std_s6_report` 本租户为 0” | S6 L1 当前实现主要读 `mdp_std_work_order_line`、`mdp_std_inv_trans` 和 DWD;必须按真实 SQL 查依赖,不能按指标名称猜 |
+| “从 `Database.json` 抄 T8 密码进 `mdp_source` 是最佳方案” | 功能上可临时接通,但不是安全终态;该文件受 Git 跟踪且存在明文凭据,终态应使用环境密钥/密钥服务并轮换已暴露口令 |
+
+### 0.4 必须遵守的仓库约束
+
+- 编码前先列实际改动文件并等待确认。
+- 任何扩大范围的修改必须补充「反向影响推演」。
+- “能力有/无”必须分代码层、配置层、数据层取证。
+- 新迁移不得修改已经成功执行的旧脚本;`AutoVersionUpdate` 会校验 SHA256。
+- 新增 `UpdateScripts/*.sql` 与 `.verify.sql` 必须在 `Admin.NET.Web.Entry.csproj` 登记。
+- 若最终提交同时包含前后端代码,server 与 Web 版本都 patch +1。
+- “提交”默认包含 commit + push。
+
+### 0.5 直接执行规则(本版已冻结设计选择)
+
+本版是**逐项直接执行版**。执行者不得在下列已经冻结的技术选择上另起方案:
+
+1. 九宫格值选择使用 MySQL 8 `ROW_NUMBER()`,不再保留“相关子查询或窗口函数二选一”。
+2. `HomeGridValueRow` 作为 `AidopKanbanController.Generic.cs` 内的私有嵌套 DTO,不新建共享 DTO,不修改 `S4ValRow`。
+3. 新鲜度字段固定为 `AdoSmartOpsKpiMaster.FreshnessSlaDays`;server 负责判定,前端只展示。
+4. 未来业务日的 `valueAgeDays` 固定钳制为 0,并记录不含业务数据内容的 warning。
+5. 员工状态不抽新框架:同步修改 Native SQL CASE 与 Inbound C# normalizer,并用契约测试锁住一致性。
+6. T8 密钥固定走环境变量 resolver;删除 `t8_v5` 注册连接与口令复制服务,不再保留两套来源。
+7. 库存物化锁固定等待 10 分钟、每 2 秒轮询一次;物化 SQL 遇 1213/1205 最多执行 3 次。
+8. 按 §10.1 固定拆包后,新鲜度迁移使用 `1.0.593.sql` / `1.0.593.verify.sql`。若执行时 server 已不再是 1.0.591,**停止执行并按四提交相对顺序整体顺延版本**,不得复用已存在版本号。
+9. WP-F 只允许按现有 `mdp_tenant_std_source` 权威绑定补通;绑定缺失或来源不提供对象时,执行者必须停下请求负责人决策,禁止自行切源。
+10. 实际密钥值注入与外部账号口令轮换是人工门禁;模型负责代码与无秘密配置,走到门禁必须明确停下,不得索要用户在对话中发送口令。
+
+---
+
+## 1. 现状基线与真实根因
+
+### 1.1 真实取数链路
+
+```text
+Web/src/views/dashboard/home.vue
+  └─ HomeModuleKpiCard.vue
+       └─ fetchHomeGrid()
+            └─ GET /api/AidopKanban/home-grid/{moduleCode}
+                 └─ AidopKanbanController.Generic.cs::GetHomeGridGeneric()
+                      ├─ 卡片:ado_smart_ops_layout_item
+                      ├─ 元数据:ado_smart_ops_kpi_master
+                      └─ 数值:ado_s9_kpi_value_l1_day
+```
+
+`GetHomeGridGeneric` 当前值 SQL 位于
+`server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs:95-109`:
+
+```sql
+AND v.biz_date=(
+  SELECT MAX(v2.biz_date)
+  ...
+  AND v2.metric_code=v.metric_code
+)
+```
+
+该 SQL 会选中“最新尝试行”,不区分它是 `OK` 还是 `NO_DATA`。
+
+前端 `HomeModuleKpiCard.vue:41-47` 又明确规定:
+
+```js
+if (status === 'NO_DATA') return '无业务数据'
+```
+
+所以,即使历史上有真实数值,只要最新一期落了 `NO_DATA`,旧值就完全不可见。
+
+### 1.2 截图对应的数据基线
+
+2026-09-29 实测,红框中 7 张 `NO_DATA` 卡背后全部存在历史真实值:
+
+| 指标 | 最新尝试 | 最后真实值 |
+|---|---|---|
+| S5_L1_003 物料仓储人效 | `NO_DATA`,2026-08-31 | 693.3333,2026-07-31 |
+| S6_L1_001 订单制造周期 | `NO_DATA`,2026-09-28 | 3.0000,2026-09-23 |
+| S6_L1_002 订单制造满足率 | `NO_DATA`,2026-09-28 | 100.0000,2026-09-23 |
+| S6_L1_003 订单制造人效 | `NO_DATA`,2026-08-31 | 1.1250,2026-07-31 |
+| S7_L1_001 订单发货周期 | `NO_DATA`,2026-09-28 | 0.9667,2026-09-23 |
+| S7_L1_003 成品仓储人效 | `NO_DATA`,2026-08-31 | 433.3333,2026-07-31 |
+| S9_L1_001 质量退货率 | `NO_DATA`,2026-08-31 | 3000.0000,2026-07-31 |
+
+可重跑 SQL:
+
+```sql
+SELECT module_code, metric_code,
+       MAX(CASE WHEN result_status='OK' AND metric_value IS NOT NULL
+                THEN biz_date END) AS last_ok_date,
+       SUBSTRING_INDEX(
+         GROUP_CONCAT(
+           CASE WHEN result_status='OK' AND metric_value IS NOT NULL
+                THEN CONCAT(DATE_FORMAT(biz_date,'%Y-%m-%d'),'=',metric_value)
+           END
+           ORDER BY biz_date DESC
+         ), ',', 1
+       ) AS last_ok
+FROM ado_s9_kpi_value_l1_day
+WHERE tenant_id=838257186181189
+  AND factory_id=1
+  AND module_code IN ('S5','S6','S7','S9')
+  AND metric_code LIKE '%\_L1\_%'
+  AND is_deleted=0
+GROUP BY module_code, metric_code
+ORDER BY module_code, metric_code;
+```
+
+> 此 SQL 只用于基线取证。实现不得使用无上界的 `GROUP_CONCAT`;项目已发生过
+> `group_concat_max_len=1024` 导致的致命截断。
+
+### 1.3 S8 不是 KPI 缺失
+
+S8 首页读取 `ado_s8_alert_record`:
+
+- 列表端点:`GET /api/AidopKanban/s8-alerts`
+- 趋势端点:`GET /api/AidopKanban/s8-home-trend`
+- 代码:`AidopKanbanController.cs:128-300`
+
+数据层实测:
+
+```sql
+SELECT COUNT(*) AS all_alerts, MAX(alert_time) AS latest
+FROM ado_s8_alert_record
+WHERE tenant_id=838257186181189 AND factory_id=1 AND is_deleted=0;
+```
+
+返回 `all_alerts=0`。这是“当前没有告警”的真实状态,不允许生成模拟告警。当前缺陷只是前端没有显式空态。
+
+### 1.4 员工状态与岗位基线
+
+代码层:
+
+- `NativeNeutralProjectionService.cs:203-208` 只识别中文“在职/离职”和
+  `ACTIVE/ONJOB/...`,不识别本系统自身真实值 `AC/DC`。
+- `MdmMirrorUpsertService.cs:523-528` 的 `NormalizeEmployment` 同样不识别 `AC/DC`。
+- S5/S7 人效分母要求 `employment_status='ACTIVE' AND position_code='WAREHOUSE'`。
+- S6 人效分母要求 `employment_status='ACTIVE' AND position_code='PRODUCTION'`
+  (`S6MdpSyncTransformService.cs:688-691`)。
+
+配置/业务定义层:
+
+- `Web/src/views/aidop/s0/warehouse/EmployeeList.vue:167-170` 明确定义
+  `AC=在职`、`DC=离职`。
+- `UpdateScripts/1.0.402.sql:93` 也把 UAT 在职员工播种为 `AC`。
+
+数据层:
+
+```text
+EmployeeMaster: AC 23 人、空状态 3 人
+mdp_std_employee: ACTIVE 0 人
+岗位:WAREHOUSE 5、PLANNER 2、OTHER 8、UNKNOWN 11、PRODUCTION 0
+```
+
+因此:
+
+- `AC → ACTIVE`、`DC → LEFT` 是确定的映射修复;
+- 空状态必须继续是 `UNKNOWN`,不得因为 `DateTerminated IS NULL` 就推断在职;
+- 不能把 `UNKNOWN` 或 `WAREHOUSE` 人员映射成 `PRODUCTION` 来填 S6。
+
+### 1.5 T8 配置基线
+
+`mdp_source.source_code='T8_V5_SQLSERVER'` 当前:
+
+- `status=1`
+- `health_status=0`
+- `db_password_enc` 为空
+- 健康信息为数据库用户登录失败
+
+`MdpSourceConnectionImportService` 当前只在 `db_host` 为空时整行导入:
+
+```sql
+WHERE source_code='T8_V5_SQLSERVER' AND IFNULL(db_host,'')=''
+```
+
+该行 host 已存在、仅密码为空,所以服务永远不会补上密码。
+
+更重要的是:现有 `Database.json` 受 Git 跟踪且包含明文数据库口令。继续把其中口令复制到数据库只是扩大秘密副本,不是安全终态。
+
+### 1.6 S6/S7/S9 当前业务输入
+
+UAT 租户数据层实测:
+
+| 输入 | 行数/最新日期 |
+|---|---|
+| S6 `PROD_TASK` 标准工单行 | 14 行,release_time 最新 2026-09-03 |
+| `PROD_RECEIPT` 库存流水 | 3790 行,approved_time 最新 2025-12-31 |
+| `dwd_work_order_mfg_fulfillment` | 最新业务日 2026-09-24 |
+| S7 `SALES_ORDER` 标准行 | 14 行 |
+| `SALES_SHIP` 库存流水 | 0 行 |
+| FQC 已报检任务 | 0 行 |
+| `SALES_RETURN` 库存流水 | 0 行 |
+
+这些是当前值算不出来的真实业务输入缺口。完成“最后真实值展示”后,页面可以展示历史事实,但当前值是否更新仍取决于权威来源是否真的提供这些业务数据。
+
+### 1.7 部署版本不一致
+
+源码基线是 Web `2.4.413`,截图页头仍显示 `V2.4.411`。这意味着前端部署物不是当前源码构建物;即使代码修好,若不发布新前端仍看不到任何新鲜度标识。
+
+---
+
+## 2. 目标数据语义与 API 契约
+
+### 2.1 两条时间线必须分开
+
+每个指标同时维护两种事实:
+
+1. **最后可用值(value row)**:最近一条真实非空数值;
+2. **最新尝试(latest attempt)**:最近一次计算结果,可能是 `OK`、`NO_DATA`、
+   `SOURCE_UNSUPPORTED` 或其它状态。
+
+禁止把两者压成一个 `resultStatus`。
+
+### 2.2 “可用值”的精确定义
+
+兼容历史数据,可用值满足:
+
+```sql
+metric_value IS NOT NULL
+AND (result_status='OK' OR result_status IS NULL OR result_status='')
+```
+
+不得把 `NO_DATA`、`SOURCE_UNSUPPORTED` 或失败状态的行当作可用值。
+
+### 2.3 `home-grid` 每个 item 的目标字段
+
+保留既有字段,并新增:
+
+```text
+currentValue             最后可用值;从未有过则 null
+targetValue              与最后可用值同一行的目标值
+resultStatus             最后可用值行状态;兼容旧前端
+resultReason             最后可用值行原因
+bizDate                  最后可用值业务日
+
+latestResultStatus       最新尝试状态
+latestResultReason       最新尝试原因
+latestBizDate            最新尝试业务日
+
+displayState             CURRENT_VALUE / LAST_KNOWN_VALUE /
+                         NO_DATA / SOURCE_UNSUPPORTED
+freshnessState           FRESH / STALE / NO_VALUE / POLICY_MISSING
+valueAgeDays             服务器自然日 - bizDate
+freshnessSlaDays         指标配置的新鲜度天数
+```
+
+判定规则:
+
+- 有可用值,且它就是最新尝试的可用值:`CURRENT_VALUE`。
+- 有可用值,但最新尝试日期更晚且最新状态不可用:`LAST_KNOWN_VALUE`。
+- 从未有可用值,最新状态是 `SOURCE_UNSUPPORTED`:`SOURCE_UNSUPPORTED`。
+- 从未有可用值,其余情况:`NO_DATA`。
+
+`LAST_KNOWN_VALUE` 必须显示数值,同时显示:
+
+```text
+数据截至 YYYY-MM-DD
+最新一期:无业务数据(YYYY-MM-DD)
+```
+
+### 2.4 日期筛选边界
+
+- 未传日期:从全部历史中找最后可用值。
+- 传 `dateStart`:可用值与最新尝试都必须 `biz_date >= dateStart`。
+- 传 `dateEnd`:可用值与最新尝试都必须 `biz_date <= dateEnd`。
+- 传完整区间:严禁从区间外回退。
+- 业务维度筛选走 `filteredBundle` 时,若旁路无法提供历史值及业务日,必须返回当前筛选结果或 `NO_DATA`,不得回退未筛选值。
+
+### 2.5 目标值一致性
+
+`targetValue` 必须取自最后可用值同一行,不能拿最新 `NO_DATA` 行的目标与历史值计算差距。`achievementLevel`、`gapValue`、`gapLabel` 都必须基于同一业务日的 value/target 对。
+
+---
+
+## 3. WP-A:重构九宫格值选择(P0)
+
+### A.1 改动文件
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/HomeGridLastKnownValueContractTests.cs`(新增)
+
+在 `AidopKanbanController.Generic.cs` 的 partial controller 内新增私有嵌套 DTO
+`HomeGridValueRow`。不要继续扩展共用的 `S4ValRow`,避免 S4 专用端点被公共契约牵连。
+
+DTO 字段固定为:
+
+```csharp
+private sealed class HomeGridValueRow
+{
+    public long Id { get; set; }
+    public string? MetricCode { get; set; }
+    public decimal? MetricValue { get; set; }
+    public decimal? TargetValue { get; set; }
+    public string? ResultStatus { get; set; }
+    public string? ResultReason { get; set; }
+    public DateTime BizDate { get; set; }
+    public DateTime? CalcTime { get; set; }
+}
+```
+
+### A.2 实现步骤
+
+1. 把当前单份 `valSql` 拆为两份,均使用 `ROW_NUMBER()`:
+   - `latestAttemptSql`:每 metric 取最新尝试;
+   - `latestUsableSql`:先按 §2.2 过滤可用值,再每 metric 取最新。
+2. 两份 SQL 必须使用完全相同的 tenant/factory/module/date 谓词。
+3. 同日存在多行时固定按 `biz_date DESC, calc_time DESC, id DESC` 稳定取一行。实表已确认三列均存在:
+   `biz_date date`、`calc_time datetime(3)`、`id bigint`。
+4. 分别构造 `latestByMetric` 与 `usableByMetric`,不得用 `ToDictionary` 吞掉重复键;SQL 必须先保证每 metric 唯一。
+5. 按 §2.3 构造返回字段。
+6. `targetValue`、gap、achievement 均读取 usable row。
+7. `filteredBundle != null` 时保持 fail-closed:不能拿无筛选历史值覆盖筛选结果。
+8. 模块级 `bizDate` 保留,只表示模块最新尝试日;注释明确不能给卡片使用。
+
+状态判断固定使用行主键,不只比较日期:
+
+```text
+usable == null:
+  latest.status == SOURCE_UNSUPPORTED ? SOURCE_UNSUPPORTED : NO_DATA
+usable != null && latest != null && usable.Id == latest.Id:
+  CURRENT_VALUE
+usable != null:
+  LAST_KNOWN_VALUE
+```
+
+历史空 `result_status` 的 usable row 在返回兼容字段 `resultStatus` 时规范成 `OK`;原始最新状态不改写。
+
+SQL 固定采用以下骨架,两份查询只在 usable 过滤条件上不同:
+
+```sql
+WITH ranked AS (
+  SELECT v.*,
+         ROW_NUMBER() OVER (
+           PARTITION BY v.metric_code
+           ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
+         ) AS rn
+  FROM ado_s9_kpi_value_l1_day v
+  WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
+    AND v.is_deleted=0
+    AND (@ds IS NULL OR v.biz_date>=@ds)
+    AND (@de IS NULL OR v.biz_date<=@de)
+    /* latestUsableSql 在这里追加:
++      AND v.metric_value IS NOT NULL
+      AND (v.result_status='OK' OR v.result_status IS NULL OR v.result_status='')
+    */
+)
+SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
+       target_value AS TargetValue, result_status AS ResultStatus,
+       result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
+FROM ranked
+WHERE rn=1;
+```
+
+### A.3 不允许的实现
+
+- 在前端缓存旧值并与新接口合并;
+- `COALESCE(latest.metric_value, old.metric_value)` 但不返回 old 的日期;
+- 把 `NO_DATA` 行直接改成 `OK`;
+- 删除 `NO_DATA` 行;
+- 日期筛选时查询区间外历史值;
+- 用模块级日期填每张卡。
+
+### A.4 测试
+
+至少覆盖:
+
+1. 最新 `NO_DATA`、前一期 `OK`:显示前一期值,状态 `LAST_KNOWN_VALUE`。
+2. 最新就是 `OK`:状态 `CURRENT_VALUE`。
+3. 历史从未有非空值:`NO_DATA`。
+4. 最新 `SOURCE_UNSUPPORTED` 且无历史值:`SOURCE_UNSUPPORTED`。
+5. 显式日期区间内无可用值、区间外有值:不得回退。
+6. value 与 target 必须来自同一行。
+7. 同一天多次计算按 `calc_time/id` 稳定选最后一次。
+8. tenant/factory/module/metric 隔离。
+9. `filteredBundle` 不回落未筛选历史值。
+
+### A.5 数据验收
+
+修复后默认请求应返回:
+
+- S6_L1_001:3.0000,`bizDate=2026-09-23`,`LAST_KNOWN_VALUE`
+- S6_L1_002:100.0000,`bizDate=2026-09-23`,`LAST_KNOWN_VALUE`
+- S7_L1_001:0.9667,`bizDate=2026-09-23`,`LAST_KNOWN_VALUE`
+- 其余基线值以 §1.2 为准
+
+不得把这些值写回成新日期;它们仍然是原业务日的历史事实。
+
+---
+
+## 4. WP-B:每指标新鲜度 SLA(P0)
+
+### B.1 为什么不能固定 7 天
+
+当前 `HomeModuleKpiCard.vue:80` 写死 `STALE_AFTER_DAYS=7`。月度人效指标以月末作为 `biz_date`,月中看到上月末值是正常行为;固定 7 天会把正常月度值长期标红。
+
+### B.2 数据模型
+
+在 `AdoSmartOpsKpiMaster` 增加可空整数字段:
+
+```csharp
+public int? FreshnessSlaDays { get; set; }
+```
+
+语义:从 value row 的 `biz_date` 起,允许保留为“新鲜”的自然日数。
+
+固定初始策略:
+
+| 指标范围 | SLA |
+|---|---:|
+| `S5_L1_003`、`S5_L1_004`、`S6_L1_003`、`S7_L1_003`、`S9_L1_001`(代码已证实按月末落值或现行月度口径) | 45 |
+| `StatFrequency='实时'` 的其它首页指标 | 1 |
+| 其余首页指标 | 7 |
+
+`StatFrequency` 现存首页值只有“天/实时”,且三个人效与库存周转虽按月末落值,元数据仍写“天”,所以迁移不能只按 `StatFrequency` 推断月度指标。上述 5 个指标必须按 code 显式置 45。以后每个指标允许通过主数据覆盖。
+
+### B.3 迁移
+
+基线固定新建:
+
+- `server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.sql`
+- `server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.verify.sql`
+- 在 `.csproj` 登记两者
+
+要求:
+
+1. 幂等增加列(先查 `information_schema.COLUMNS`)。
+2. 先对上述 5 个 code 写 45,再对 `StatFrequency='实时'` 且仍为空的首页指标写 1,最后对其它仍为空的首页指标写 7。
+3. 所有 `IsHomePage=1 AND IsEnabled=1` 的指标必须有正数 SLA。
+4. verify 返回单个布尔,保证首页启用指标无空/非正数 SLA。
+5. 不修改任何已成功执行的旧迁移。
+
+迁移 SQL 按以下固定顺序编写:
+
+```sql
+SET @has_freshness_sla := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA=DATABASE()
+    AND TABLE_NAME='ado_smart_ops_kpi_master'
+    AND COLUMN_NAME='FreshnessSlaDays'
+);
+SET @ddl := IF(
+  @has_freshness_sla=0,
+  'ALTER TABLE ado_smart_ops_kpi_master ADD COLUMN FreshnessSlaDays int NULL COMMENT ''首页数据新鲜度SLA(自然日)'' AFTER StatFrequency',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=45, UpdatedAt=NOW()
+WHERE MetricCode IN
+  ('S5_L1_003','S5_L1_004','S6_L1_003','S7_L1_003','S9_L1_001');
+
+-- ③ 实时
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=1, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND StatFrequency='实时'
+  AND FreshnessSlaDays IS NULL;
+
+-- ④ 其余首页指标
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=7, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND FreshnessSlaDays IS NULL;
+```
+
+verify 固定检查:
+
+```sql
+SELECT
+  (SELECT COUNT(*) FROM information_schema.TABLES
+   WHERE TABLE_SCHEMA=DATABASE()
+     AND TABLE_NAME='ado_smart_ops_kpi_master')=0
+  OR (
+    (SELECT COUNT(*) FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA=DATABASE()
+       AND TABLE_NAME='ado_smart_ops_kpi_master'
+       AND COLUMN_NAME='FreshnessSlaDays')=1
+    AND
+    (SELECT COUNT(*) FROM ado_smart_ops_kpi_master
+     WHERE IsHomePage=1 AND IsEnabled=1
+       AND (FreshnessSlaDays IS NULL OR FreshnessSlaDays<=0))=0
+  );
+```
+
+### B.4 种子
+
+`AidopKpiMasterSeed` 新建租户时必须同步写 `FreshnessSlaDays`。将“频率 → SLA”的映射集中在一个 helper,避免每条 `Ins(...)` 手填并逐渐漂移。
+
+helper 名称固定为
+`ResolveFreshnessSlaDays(string metricCode, string? statFrequency)`,返回 `int`。
+固定按同一优先级:5 个明确月度 code → 45,实时 → 1,其余 → 7。`Ins(...)`
+创建实体时调用该 helper。API 仍保留
+`POLICY_MISSING`,用于拦截手工新增却漏配置的指标。
+
+### B.5 服务端计算
+
+服务端按 `DateTime.Today - bizDate.Date` 计算 `valueAgeDays`,再与 KPI 的 SLA 比较。前端不得自行读取本机日期重新判定,以免客户端时区不同导致结果不一致。
+
+规则:
+
+- 无 value:`NO_VALUE`
+- 无 SLA:`POLICY_MISSING`
+- `valueAgeDays > freshnessSlaDays`:`STALE`
+- 其余:`FRESH`
+
+### B.6 测试
+
+- 日指标 8 天 → STALE;7 天 → FRESH。
+- 月指标 30 天、SLA 45 → FRESH。
+- 月指标 46 天 → STALE。
+- 无策略 → POLICY_MISSING,不得默认 FRESH。
+- 未来业务日(时钟异常)固定记录 warning,并把 `valueAgeDays` 钳制为 0;warning 只带 tenant/factory/metric/date,不带业务报文。
+
+---
+
+## 5. WP-C:前端真实展示与 S8 空态(P0)
+
+### C.1 改动文件
+
+- `Web/src/views/aidop/api/kanbanData.ts`
+- `Web/src/views/dashboard/components/HomeModuleKpiCard.vue`
+- `Web/src/views/dashboard/components/homeKpiPresentation.ts`(新增)
+- `Web/src/views/dashboard/components/HomeCardGridView.vue`
+- `Web/src/views/dashboard/components/HomeCardTilesView.vue`
+- `Web/src/views/dashboard/components/HomeCardTableView.vue`
+- `Web/src/views/dashboard/home.vue`
+- `Web/src/views/dashboard/components/homeKpiPresentation.spec.ts`(新增)
+- `Web/src/views/dashboard/home.s8-empty.spec.ts`(新增)
+
+### C.2 卡片展示规则
+
+把 `fmtResult`、日期/状态提示和 cell 映射移入
+`homeKpiPresentation.ts`,导出纯函数 `mapHomeKpiItem(item)`,供组件与 Vitest 共用。
+移除前端固定 `STALE_AFTER_DAYS`,完全消费服务端状态。
+
+| displayState | 主值 | 辅助说明 |
+|---|---|---|
+| CURRENT_VALUE | 正常显示 | 数据截至 X |
+| LAST_KNOWN_VALUE | 显示最后真实值 | 数据截至 X;最新一期无业务数据/数据源不支持 |
+| NO_DATA | 无业务数据 | 最新尝试原因 |
+| SOURCE_UNSUPPORTED | 数据源不支持 | 最新尝试原因 |
+
+`freshnessState=STALE` 时弱化卡片,但不得隐藏数值。
+
+`freshnessState=POLICY_MISSING` 时显示中性提示“未配置新鲜度规则”,不要擅自变灰。
+
+### C.3 防止窄卡竖排
+
+截图中 S9 的「无业务数据」因窄列换行成竖排。三种布局都应:
+
+- `mapHomeKpiItem` 固定返回 `valueKind: 'number' | 'status'`;
+- 三个布局的 value 元素固定绑定 `--status` class;
+- status class 固定使用 `font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%`;
+- 保持数值、单位、状态三种文本的布局规则分离。
+
+不能仅依赖“以后会显示旧值”掩盖这个布局缺陷,因为从未有值的指标仍会显示状态文本。
+
+### C.4 最新尝试提示
+
+tooltip 固定按非空行依次拼接:
+
+```text
+指标公式(如有)
+最后真实值:YYYY-MM-DD
+最新计算:YYYY-MM-DD / NO_DATA / 原因
+新鲜度:N 天 / SLA M 天
+```
+
+不得把后端 `resultReason` 原样拼出敏感 SQL、连接串或凭据;只显示业务安全文本。
+前端只展示后端已提供的业务原因字段;若文本含 `Password=`、`Pwd=`、`ConnectionString`、
+`Server=` 任一片段,则整条原因替换为「计算状态异常,请查看运行日志」。
+
+### C.5 S8 空态
+
+`home.vue` 当前 `s8TopAlarmsAll=[]` 时列表区域直接空白。改为:
+
+- 报警列表为空:显示「暂无异常告警」;
+- 趋势全 0:保留真实 0 折线/坐标,同时显示轻量“近 7 日无异常”;
+- 请求失败:显示「异常数据加载失败」,不能与“无告警”混为一谈;
+- 不恢复已删除的硬编码演示告警。
+
+### C.6 前端测试
+
+- LAST_KNOWN_VALUE 显示数值而不是“无业务数据”。
+- 最新 `NO_DATA` 徽标可见。
+- STALE 弱化,FRESH 不弱化。
+- 月度值不由前端 7 天规则误判。
+- NO_DATA 状态文本不换成逐字竖排。
+- S8 空数组显示“暂无异常告警”。
+- S8 请求异常显示“加载失败”。
+
+---
+
+## 6. WP-D:员工状态中立映射根治(P1)
+
+### D.1 改动点
+
+固定修改:
+
+- `DataPlatform/NativeNeutralProjectionService.cs`
+- `DataPlatform/Inbound/MdmMirrorUpsertService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/EmployeeEmploymentStatusMappingTests.cs`(新增)
+
+`T8BaseInboundMdpSyncService.NeutralProjection.cs` 已按 `在职/离职` 正确转换,只做只读回归核查,不改代码。
+
+### D.2 映射规则
+
+必须加入:
+
+```text
+AC → ACTIVE
+DC → LEFT
+```
+
+保留已有规则:
+
+- 在职 / ACTIVE / ONJOB / ON_JOB → ACTIVE
+- 离职类 → LEFT
+- 停用类 → INACTIVE
+- 空值/未知值 → UNKNOWN
+
+不抽取新公共框架。本轮固定同步修改两处:
+
+- Native SQL CASE:`AC → ACTIVE`、`DC → LEFT`
+- Inbound `NormalizeEmployment`:`AC → ACTIVE`、`DC → LEFT`
+
+新增契约测试同时读取两处源码并断言 AC/DC 规则存在,防止以后漂移。
+
+### D.3 数据重投影
+
+代码部署后必须通过正式投影入口重跑,不直接 UPDATE `mdp_std_employee`:
+
+1. 触发 AIDOP_NATIVE 员工中立投影;
+2. 验证 `src_employment_status_raw='AC'` 的行变为 `ACTIVE`;
+3. 验证空状态仍为 `UNKNOWN`;
+4. 再重算 S5/S7。
+
+验收 SQL:
+
+```sql
+SELECT source_system, employment_status, position_code, COUNT(*) AS n
+FROM mdp_std_employee
+WHERE tenant_id=838257186181189
+GROUP BY source_system, employment_status, position_code
+ORDER BY source_system, employment_status, position_code;
+```
+
+预期:AIDOP_NATIVE 至少出现 5 个 `ACTIVE + WAREHOUSE`。
+
+### D.4 明确不能承诺的结果
+
+- S5/S7 当前月分子若仍为空,人效卡仍可能只展示 7 月最后真实值。
+- S6 没有 `PRODUCTION` 岗位员工,不能通过 AC/DC 修复得到当前人效。
+- 不得把 11 个空岗位员工批量映射为生产岗位。
+
+---
+
+## 7. WP-E:T8 密钥与连接终态(P1,安全相关)
+
+### E.1 目标架构
+
+`mdp_source` 保存非秘密元数据;口令由部署环境的密钥提供器解析。环境变量约定固定为:
+
+```text
+AIDOP_MDP_SOURCE_<规范化SOURCE_CODE>_PASSWORD
+```
+
+规范化算法固定为:
+
+```csharp
+Regex.Replace(sourceCode.Trim().ToUpperInvariant(), "[^A-Z0-9]", "_")
+```
+
+因此 T8 的变量名固定为 `AIDOP_MDP_SOURCE_T8_V5_SQLSERVER_PASSWORD`。任务书只允许记录变量名,不允许记录变量值。
+
+### E.2 代码改动
+
+1. 新增 `DataPlatform/MdpSourcePasswordResolver.cs`,同文件定义
+   `IMdpSourcePasswordResolver` 与 `MdpSourcePasswordResolver`:
+   - 优先从环境密钥读取;
+   - 兼容现有 `db_password_enc` 的系统密文;
+   - 旧明文仅作为迁移期兼容,并记录“不含值”的安全告警;
+   - 找不到口令时抛明确的配置异常,禁止用空字符串尝试登录后再报模糊错误。
+2. `MdpSourceScopeFactory` 通过 resolver 获取口令,不自行解密/回退。
+3. `MdpSourceHealthCheckJob` 区分:
+   - `SECRET_MISSING`
+   - `AUTH_FAILED`
+   - `NETWORK_FAILED`
+   健康信息不得包含连接串和口令。
+4. 删除 `DataPlatform/MdpSourceConnectionImportService.cs`,并删除 `Startup.cs` 中对应
+   `AddHostedService` 注册。该服务只处理 `t8_v5`,终态不再需要。
+5. 从受 Git 跟踪的 `server/Admin.NET.Application/Configuration/Database.json` 中完整删除
+   `ConfigId="t8_v5"` 对象;不得在 diff、日志或交付报告中粘贴原连接串。
+6. 更新所有声称“复用 Database.json 的 t8_v5”的代码注释。
+7. 新增 `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpSourcePasswordResolverTests.cs`:
+   - 环境变量优先;
+   - 环境缺失时兼容系统密文;
+   - 两者都缺失时抛 `SECRET_MISSING`;
+   - 日志和异常不含测试密码。
+8. **人工门禁 E-M1**:运维在进程环境注入
+   `AIDOP_MDP_SOURCE_T8_V5_SQLSERVER_PASSWORD` 后重启。
+9. **人工门禁 E-M2**:负责人轮换已经进入 Git 历史的旧 T8 口令,并确认旧口令失效。
+
+本包固定文件清单:
+
+- 新增 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourcePasswordResolver.cs`
+- 修改 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs`
+- 修改 `server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs`
+- 修改 `server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs`
+- 修改 `server/Admin.NET.Application/Configuration/Database.json`(只删除 T8 注册对象)
+- 删除 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConnectionImportService.cs`
+- 更新 `T8BaseInboundMdpSyncService.cs` 等处关于 `t8_v5` 的失效注释
+- 新增 `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpSourcePasswordResolverTests.cs`
+- 更新所有直接 `new MdpSourceScopeFactory(...)` 的测试调用点,显式传入 fake resolver
+
+resolver 固定解析优先级:
+
+```text
+1. 对应 source_code 的环境变量(非空)
+2. db_password_enc 经 CryptogramUtil.Decrypt 成功得到的非空值
+3. 兼容旧明文 db_password_enc,同时写安全 warning(不含值)
+4. 抛 MdpSourceSecretException,错误码 SECRET_MISSING
+```
+
+禁止把环境变量值缓存进实体、数据库或日志。resolver 可在进程内读取环境变量,每次新建连接时解析。
+
+### E.3 不允许的方案
+
+- 把口令写进新迁移;
+- 把口令写进 `appsettings*.json`、`.env.example` 或任务书;
+- 从 Git 历史读取旧口令继续使用但不轮换;
+- 只把 `MdpSourceConnectionImportService` 条件改成 `db_password_enc IS NULL`,继续复制仓库明文;
+- 在日志中打印脱敏不完整的连接串。
+
+### E.4 验收
+
+- `mdp_source.db_password_enc` 可以继续为空,但健康检查应通过环境密钥连接成功。
+- 缺少环境密钥时,健康状态为 `SECRET_MISSING`,信息可定位但不含秘密。
+- 错误口令时为 `AUTH_FAILED`。
+- Git 当前树不再包含 T8 口令。
+- 外部 T8 账号已轮换;旧口令失效。
+- 连接权限保持只读。
+
+> 主库 MySQL 明文凭据也存在于同一受跟踪配置文件中。若本轮不一并外部化,
+> 必须登记独立 P0 安全任务,不能在交付说明里声称“仓库凭据问题已全部解决”。
+
+---
+
+## 8. WP-F:S6/S7/S9 当前业务数据补通(P1)
+
+### F.1 先查来源绑定,禁止想当然切源
+
+```sql
+SELECT tenant_id, std_object, source_system, source_scope
+FROM mdp_tenant_std_source
+WHERE tenant_id=838257186181189
+  AND std_object IN ('INV_TRANS','EMPLOYEE','WO_LINE_PROD','WO_LINE_SALES','FQC')
+ORDER BY std_object;
+```
+
+执行规则固定为:
+
+1. 查询命中一条绑定:只沿该来源继续代码/配置/数据取证。
+2. 查询 0 条或同一 std_object 多条:停止 WP-F,输出证据并请求负责人决策。
+3. 已绑定来源不提供所需业务对象:停止 WP-F,列出 T8/165/自建单/标准 API 的能力矩阵,请负责人选权威来源。
+4. 不得为了让卡片出数而自行改绑定。
+
+### F.2 每个缺口必须完成三层取证
+
+#### S6_L1_001 / S6_L1_002
+
+- 代码层:读取 `S6MdpSyncTransformService.cs:566-649` 的真实 SQL。
+- 配置层:核查 `INV_TRANS`、生产工单来源绑定及相关实体配置。
+- 数据层:核查 `PROD_TASK`、`PROD_RECEIPT`、DWD 的最新业务日和可完成订单数。
+
+#### S6_L1_003
+
+- 除上述分子外,必须有 `ACTIVE + PRODUCTION` 员工。
+- 当前 `PRODUCTION=0`,必须由真实员工岗位主数据补齐。
+
+#### S7_L1_001
+
+代码要求同时具备:
+
+- `mdp_std_work_order_line.doc_type='SALES_ORDER'`
+- `mdp_std_inv_trans.biz_doc_type='SALES_SHIP'`
+- `mdp_std_fqc_task.apply_flag=1`
+- 完成判定成立
+
+当前后两项为 0。执行者应检索 T8、165、自建单和标准 API 四条来源,确认代码能力、配置开通和真实数据分别缺在哪一层。
+
+#### S9_L1_001
+
+质量退货率需要 `SALES_RETURN`。当前为 0。只有权威业务源确实存在退货时才补通;若该租户从未发生销售退货,继续显示“最后真实值 + 最新一期无业务数据”是正确结果。
+
+### F.3 补数原则
+
+- 优先重放真实源数据或标准 API 入站;
+- 保留原业务日期、来源系统、业务键和批次;
+- 不把历史值复制成今天;
+- 不复制其它租户;
+- 不直接改 KPI 结果表;
+- 补数后按来源 → stg → std → dwd → KPI 顺序验证。
+
+执行模型在 F.1 第 2/3 条门禁未解除前,只能做只读取证和报告,不能写配置、补数据或改来源选择代码。
+
+### F.4 验收
+
+每个指标只能得出以下三种明确结论之一:
+
+- 代码有 / 配置开 / 数据通;
+- 代码有 / 配置未开(列配置项);
+- 代码有 / 配置开 / 数据未通(列源对象及最新日期);
+- 若代码无,必须给出全仓零匹配范围与拟新增文件。
+
+不得用“未达到”“大概缺数据”收尾。
+
+---
+
+## 9. WP-G:库存标准层物化互斥缺口与 02:00 死锁(P0)
+
+### G.1 已确认根因
+
+`InventoryMdpSyncService` 的无参 `TransformTransStdFromStgAsync()`、库存余额投影、正常同步都使用 `InventoryInboundLockGuard`。
+
+但 S5/S7 重算调用的是另一个重载:
+
+```csharp
+TransformTransStdFromStgAsync(long tenantId, ...)
+```
+
+该重载在 `InventoryMdpSyncService.cs:257-304` 直接调用
+`MaterializeInvTransStdAsync`,**没有获取同一把锁**。
+
+调用面:
+
+- `S5MdpSyncTransformService.cs:111`
+- `S7MdpSyncTransformService.cs:100`
+
+2026-09-29 02:00,半小时同步与 AUTO S5 同秒运行,在
+`MaterializeInvTransStdAsync:869` 出现 MySQL deadlock;人工错峰重算成功。调度错开只能降低概率,不是根治。
+
+### G.2 最佳修法
+
+1. 为 `TransformTransStdFromStgAsync(long tenantId)` 补同一
+   `InventoryInboundLockGuard.LockKey`。
+2. 该入口是用户/重算链路,不应像后台增量任务一样“抢不到立即静默跳过”:
+   - 在 `InventoryInboundLockGuard` 新增 `AcquireAsync(...)`;
+   - 总等待上限固定 10 分钟;
+   - DB 咨询锁每 2 秒用 `GET_LOCK(key,0)` 非阻塞轮询;
+   - 每轮检查 cancellation token;
+   - 等待期间不得占用错误的共享 SqlSugar 自动关闭连接;
+   - 超时固定抛 `TimeoutException("inventory materialization lock busy")`;
+   - 不允许返回 0 并把“未物化”伪装成成功。
+3. 保留数据库死锁的有限重试作为最后防线:
+   - 仅识别 `MySqlException.Number` 1213(deadlock)与 1205(lock wait timeout);
+   - 最多 3 次总尝试(首次 + 2 次重试);
+   - 两次重试延迟固定 250ms、750ms;
+   - 非并发类 SQL 错误立即抛出。
+4. 全仓检索已知调用方固定为 S5、S7 与管理入口。若执行时发现新增调用方已持锁,停止 G 包并报告调用栈;不得在不同连接上嵌套获取同名锁。
+
+### G.3 测试
+
+固定新增:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryInboundLockGuardTests.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryMaterializationRetryTests.cs`
+
+- 同进程两路:第二路等待,第一路释放后获得锁。
+- 跨连接两路:只有一路进入物化区。
+- 取消令牌能终止等待。
+- 超时给出 busy/retryable,不返回假成功。
+- 异常路径释放咨询锁。
+- 非 deadlock SQL 异常不重试。
+- S5 与 S7 同时请求时串行,不死锁。
+
+### G.4 运行验收
+
+- 在半小时同步窗口附近触发 S5/S7 重算;
+- `ado_module_dashboard_rebuild_job` 最终 SUCCESS 或明确 busy/retryable;
+- 后端日志无 `Deadlock found when trying to get lock`;
+- `mdp_source_gate_log` 仍正常写入;
+- 不允许只通过把 02:00 调度改成 02:05 验收。
+
+---
+
+## 10. 执行顺序
+
+固定执行顺序:
+
+```text
+WP-G 互斥根治
+  → WP-A 双时间线取值
+  → WP-B 新鲜度配置
+  → WP-C 前端展示与 S8 空态
+  → WP-D 员工状态
+  → WP-E T8 密钥
+  → WP-F 真实源补通
+  → 全量验收与部署
+```
+
+依赖关系:
+
+- A/B/C 必须同一发布窗口完成,否则 API 与前端契约不一致。
+- D/E/F 可以独立于展示契约,但最终 UAT 放行前必须给出完成状态。
+- G 应先完成,避免后续反复重算时继续死锁。
+- E 涉及密钥轮换,必须与运维约定切换窗口。
+
+### 10.1 固定提交批次
+
+为降低跨模块审查风险,固定拆成 4 个提交,依次 commit + push:
+
+1. **提交 1(server)**:WP-G 互斥与重试;server 版本从 1.0.591 升 1.0.592。
+2. **提交 2(server + Web)**:WP-A/B/C、迁移 `1.0.593`(因为提交 1 已占
+   1.0.592)、server 升 1.0.593、Web 升 2.4.414。
+3. **提交 3(server)**:WP-D 员工状态;server 升 1.0.594。
+4. **提交 4(server/config)**:WP-E 密钥 resolver 与移除 t8_v5;server 升 1.0.595。
+
+本节覆盖 §0.5 第 8 条的单提交迁移预案:既然现在固定拆包,**新鲜度迁移最终固定为
+`1.0.593.sql` / `1.0.593.verify.sql`**,并在提交 2 登记。若执行前任何基线版本已变化,
+整套版本号按相同相对顺序顺延,执行者先报告新映射再开始,不得自行复用版本。
+
+WP-F 不预设代码提交:先完成只读取证;只有负责人解除来源门禁后,另列改动清单并确认。
+
+当前任务书文件尚未纳入基线提交;执行者把本任务书加入提交 1。每个提交只 stage 本节列出的任务文件和对应版本文件,禁止 `git add .`,不得把工作区其它既有改动带入。
+
+---
+
+## 11. 反向影响推演
+
+### 11.1 代码调用面
+
+**需同步改**:
+
+- `GetHomeGridGeneric` 被 S1–S7、S9 九宫格共同调用;
+- `HomeModuleKpiCard` 与三种布局组件共同影响所有非 S8 模块;
+- 员工状态投影影响 S3 计划人效、S5/S6/S7 人效,不只截图三张卡;
+- `InventoryInboundLockGuard` 被库存同步、后台管理、重算队列抢占等多个入口复用。
+
+执行者必须全仓检索调用点,不能只回归 S5–S9。
+
+### 11.2 数据契约面
+
+**需同步改**:
+
+- `home-grid.items[]` 增加最新尝试、展示状态和新鲜度字段;
+- `ado_smart_ops_kpi_master` 增加新鲜度 SLA;
+- KPI 原始结果表不改、不删、不回填假值;
+- 员工标准层通过正式投影更新。
+
+需要同步核查智慧诊断、ChatBI、导出是否直接调用 `home-grid`。若调用,仅新增字段应向后兼容;若调用方把 `resultStatus` 当“最新计算状态”,必须改读 `latestResultStatus`。
+
+### 11.3 多数据源面
+
+**需同步核查**:
+
+- T8、165 MES/WMS、自建单、标准 API 入站是否都能提供
+  `PROD_RECEIPT/SALES_SHIP/SALES_RETURN/FQC/EMPLOYEE`;
+- 每个租户按 `mdp_tenant_std_source` 选择唯一权威来源;
+- 某来源缺字段时必须落明确 NO_DATA/unsupported,不得跨源拼凑。
+
+### 11.4 多租户 / Domain 面
+
+**无影响(前提是严格保留现有过滤)**:
+
+- 两份值查询都必须带 tenant、factory、module、metric;
+- 补数与重投影只作用于目标租户;
+- 不得复制 tenant 797403760988229 的 T8 数据给 838257186181189;
+- Domain/source_scope 继续由现有 resolver 决定。
+
+### 11.5 运行面
+
+**需同步改**:
+
+- 新迁移、verify 与 csproj 登记;
+- 新环境密钥与口令轮换;
+- 前后端重新构建部署;
+- 截图当前 V2.4.411,必须验证页头版本已变为本次新版本;
+- 02:00/半小时同步并发场景必须现场验收。
+
+### 11.6 验证面
+
+**需同步改**:
+
+- 后端值选择测试;
+- 新鲜度策略测试;
+- 员工状态契约测试;
+- 锁并发测试;
+- 前端三布局 + S8 空态测试;
+- S1–S9 API 快照/语义回归;
+- 真实数据库验收 SQL与截图。
+
+---
+
+## 12. 版本、迁移、构建与提交
+
+### 12.1 版本
+
+严格按 §10.1 四个提交递增:
+
+- 提交 1:server 1.0.592
+- 提交 2:server 1.0.593 / Web 2.4.414;迁移 1.0.593
+- 提交 3:server 1.0.594
+- 提交 4:server 1.0.595
+
+执行前若基线版本不再是 server 1.0.591 / Web 2.4.413,停止并按当前版本整体顺延,先向用户报告,不得覆盖或重复版本。
+
+纯任务书提交不升前后端版本。
+
+### 12.2 构建
+
+```powershell
+dotnet build server\Admin.NET.Web.Entry\Admin.NET.Web.Entry.csproj -v q --nologo
+dotnet build server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj -v q --nologo
+dotnet test server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj `
+  --no-build --nologo --logger "trx;LogFileName=grid-freshness.trx"
+
+Set-Location Web
+npm run test:run
+npm run build
+```
+
+若 DLL 被运行中的后端锁定,先正常停止该进程再构建。不得使用破坏性 Git 命令清理用户工作区。
+
+基线全量测试是 3217 条、7 条失败。新增测试后总数会增加,但失败集合不得扩大。固定基线失败为:
+
+1. `S0DimContractTests.ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere`
+2. `S1RequirementExamineDwdContractTests.Table_HasNoS8SpecificColumns`
+3. `S1RequirementExamineDwdContractTests.CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration`
+4. `S1RequirementExamineDwdContractTests.BooleanColumn_FollowsRepoFlagConvention_NotBit`
+5. `S1RequirementExamineDwdContractTests.NewColumns_ExistInBothInlineDdlAndMigration`
+6. `S1RequirementExamineDwdContractTests.QuantityColumns_DocumentedAsStandardNames`
+7. `S6ProductionInstructionContractTests.Std_Schedule_HasNewNullableColumns`
+
+用 TRX 比较失败用例名,不以控制台中文输出为准。测试完成后删除本轮 `TestResults`。
+
+### 12.3 提交排除项
+
+不得提交:
+
+- `bin/`、`obj/`、`TestResults/`
+- `_tmp_*.log`、`frontend-dev.log`
+- `~$*.docx`
+- `.env` 或任何真实口令
+- 构建产物
+
+---
+
+## 13. 总验收清单
+
+### 公共取数
+
+- [ ] 最新 NO_DATA 不再遮住区间内最后真实值
+- [ ] 最新尝试状态与最后真实值状态分开返回
+- [ ] target/value/bizDate 来自同一行
+- [ ] 显式日期筛选不跨区间回退
+- [ ] 从未有值仍显示无业务数据
+- [ ] 业务维度筛选不回落未筛选历史值
+
+### 新鲜度
+
+- [ ] 首页启用指标全部配置正数 SLA
+- [ ] 日/月指标按各自 SLA 判定
+- [ ] 前端无固定 7 天常量
+- [ ] POLICY_MISSING 明确可见
+
+### 前端
+
+- [ ] S6_L1_001、002 与 S7_L1_001 显示最后真实值和日期
+- [ ] 7 月人效/退货率值弱化并显示最新 NO_DATA 提示
+- [ ] “无业务数据”不再竖排
+- [ ] S8 0 行显示“暂无异常告警”
+- [ ] S8 请求失败与无告警严格区分
+- [ ] 页头版本不是旧的 V2.4.411
+
+### 员工映射
+
+- [ ] AC → ACTIVE
+- [ ] DC → LEFT
+- [ ] 空状态仍 UNKNOWN
+- [ ] 至少 5 个 ACTIVE + WAREHOUSE
+- [ ] 未伪造 PRODUCTION 岗位
+
+### T8 与安全
+
+- [ ] 口令来自部署密钥,不来自 Git 跟踪文件
+- [ ] health 能区分 SECRET_MISSING / AUTH_FAILED / NETWORK_FAILED
+- [ ] 当前树无 T8 明文口令
+- [ ] 已轮换历史暴露口令
+- [ ] 日志无连接串/口令
+
+### 互斥与运行
+
+- [ ] tenant 重载物化入口也持有库存咨询锁
+- [ ] 抢锁有界等待、可取消、超时不假成功
+- [ ] 02:00 并发场景无 deadlock
+- [ ] S5/S7 重算成功
+
+### 数据真实性
+
+- [ ] 未直接更新 KPI 值表
+- [ ] 未复制其它租户
+- [ ] 未把历史日期改成今天
+- [ ] 每个仍无当前值的指标都有代码/配置/数据三层结论
+
+### 工程
+
+- [ ] 后端构建 0 error
+- [ ] 新增测试通过
+- [ ] 既有失败集合不扩大
+- [ ] 前端测试与构建通过
+- [ ] 迁移与 verify 已登记且执行成功
+- [ ] 版本号正确
+- [ ] commit + push 成功并汇报远端哈希
+
+---
+
+## 14. 回滚原则
+
+1. API 新字段是增量字段;如需回滚前端,可保留后端字段。
+2. 不回滚或删除历史 KPI 行。
+3. 新鲜度列回滚时先停止读取,不急于 DROP;DROP 必须另发迁移。
+4. 员工状态映射回滚后,通过正式投影重建标准层,禁止手改。
+5. 密钥切换回滚不得把口令重新提交进仓库;只能切回上一安全密钥版本。
+6. 咨询锁改动若出现阻塞,先关闭相关作业并取证锁持有者;不得通过删除业务数据解除。
+
+---
+
+## 15. 交付报告模板
+
+执行者最终必须按以下结构汇报:
+
+```text
+1. 提交与版本
+2. 实际改动文件
+3. 迁移执行结果
+4. 九宫格逐卡验收
+5. 员工投影结果
+6. T8 健康与密钥状态(不得写口令)
+7. 02:00 并发验收
+8. 测试结果及既有失败对比
+9. 反向影响推演
+   - 代码调用面:无影响 / 需同步改(文件)
+   - 数据契约面:无影响 / 需同步改(文件/表)
+   - 多数据源面:无影响 / 需同步改(来源)
+   - 多租户/Domain:无影响 / 需同步改
+   - 运行面:无影响 / 需同步改
+   - 验证面:无影响 / 需同步改
+10. 仍无当前业务数据的指标及三层证据
+```
+
+只有“页面有数字”不算完成;只有在数字来源、业务日期、最新状态、新鲜度和来源链路都可解释时,任务才完成。
+
+---
+
+## 16. 给执行模型的启动指令
+
+把下列文字连同本文件路径交给新的执行模型:
+
+```text
+请严格执行 doc/plan/九宫格最后真实值与数据新鲜度根治执行任务书.md。
+
+先做以下事情,不要立即改文件:
+1. 读取 AGENTS.md 和所有 alwaysApply 规则。
+2. 核对 HEAD、server/Web 版本、工作区全部未提交文件;不得覆盖或提交他人改动。
+3. 重新运行任务书中的只读基线 SQL。
+4. 按 §10.1 列出四个提交的精确文件清单、跨模块影响与不做事项,等待我确认。
+
+确认后严格按 WP-G → WP-A/B/C → WP-D → WP-E 顺序执行并逐提交构建、测试、commit、push。
+不得更改已冻结的设计选择,不得修改历史成功迁移,不得把任何口令打印到对话、日志或 diff 摘要。
+
+到 E-M1/E-M2 人工密钥门禁时停止并只告诉我需要在部署环境完成什么,不得要求我在对话中发送口令。
+WP-F 先只读取证;mdp_tenant_std_source 缺绑定、多绑定或已绑定来源不提供对象时停止并请求负责人决策,禁止自行切源、复制其它租户或直接改 KPI 表。
+
+完成后按 §15 模板交付,并附可重跑的 SQL、测试失败集合对比、远端 commit 哈希和六面反向影响推演。
+```
+
+该启动指令不授权执行模型跳过仓库的“改动前确认”规则;它只固定执行方法与停机条件。

+ 905 - 0
doc/plan/系统集成/Ai-DOP第三方对接模拟器初步执行任务书.md

@@ -0,0 +1,905 @@
+# Ai-DOP 第三方对接模拟器初步执行任务书
+
+> 派生自 [`Ai-DOP第三方对接模拟器建设方案.md`](./Ai-DOP第三方对接模拟器建设方案.md)。  
+> 本任务书是第一版实施拆解,供后续开发者或大模型执行;正式编码前仍须按仓库协作规则列出准确改动清单并确认。  
+> 当前基线:HEAD `5f6600ecf`,server `1.0.596`,Web `2.4.415`。  
+> 编制日期:2026-09-30。
+
+---
+
+## 0. 任务目标
+
+建设独立本地可视化第三方系统模拟器。
+
+第一阶段必须能真实验证:
+
+1. 数据库同步 `DB_SYNC`;
+2. Ai-DOP 主动接口拉取 `API_PULL`;
+3. 第三方主动接口推送 `API_INBOUND`。
+
+第二阶段再建设自动化场景、故障注入、全链路对账、SQL Server、CI 和容器化能力。
+
+第一阶段不得只把现有 `push_demo.py` 包一层页面后宣布完成,也不得只证明 Mock 端自己能响应。三种方式都必须经过 Ai-DOP 现有真实执行链。
+
+---
+
+## 1. 开工门禁
+
+### G1:范围确认
+
+已确认:
+
+- 模拟器独立运行,不进入 Ai-DOP 菜单;
+- 第一阶段包含三种入站方式;
+- 第一阶段 DB_SYNC 只模拟 MySQL;
+- API_INBOUND 覆盖现有 26 个代码契约和 7 类协议操作;
+- 业务数据范围以业务页和技术页为准。
+
+### G2:环境确认
+
+执行前确认:
+
+- Python 3.11+;
+- 可安装 FastAPI、Uvicorn、PyMySQL;
+- 有独立 MySQL 测试 schema 的建表与读写权限;
+- Ai-DOP 开发或 UAT 地址可访问;
+- 有测试租户;
+- DB_SYNC/API_PULL 测试来源不会覆盖现有生产权威来源;
+- API_INBOUND 测试 AccessKey 绑定测试租户;
+- Secret 由环境变量或启动时输入提供。
+
+### G3:三层基线
+
+执行前保存:
+
+#### 代码层
+
+```text
+rg "class MdpDbPullExecutor|class MdpApiPullExecutor|class MdpInboundController" server/Plugins/Admin.NET.Plugin.AiDOP
+rg "\\[\"[A-Z0-9_]+\"\\]\\s*=" server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs
+```
+
+#### 配置层
+
+```sql
+SELECT source_code, source_type, system_code, status
+FROM mdp_source
+ORDER BY source_code;
+
+SELECT entity_code, source_id, source_table_name, source_api_path,
+       target_table_name, inbound_enabled, incr_column
+FROM mdp_entity
+ORDER BY entity_code, source_id;
+```
+
+授权查询须在安全客户端执行,不输出 AccessSecret:
+
+```sql
+SELECT access_key, entity_code, tenant_id, source_code, status
+FROM mdp_inbound_grant
+ORDER BY access_key, entity_code;
+```
+
+#### 数据层
+
+```sql
+SELECT source_code, status, COUNT(*) AS cnt, MAX(end_time) AS latest_end
+FROM mdp_sync_log
+GROUP BY source_code, status;
+
+SELECT status, COUNT(*) AS cnt, MAX(created_at) AS latest_request
+FROM mdp_inbound_request
+GROUP BY status;
+```
+
+### G4:停止门禁
+
+出现以下任一情况立即停止,不得用临时写库绕过:
+
+- 目标为生产环境;
+- 无法确认测试租户;
+- 测试来源会替换当前标准对象权威来源;
+- 需要把真实 Secret 写进仓库;
+- 需要直接写 Ai-DOP 贴源/标准/KPI 表才能“跑通”;
+- 现有 Ai-DOP 配置缺失且用户未授权补配置;
+- 样例业务键与真实业务键存在碰撞风险。
+
+---
+
+## 2. 两阶段总览
+
+| 阶段 | 目标 | 工作包 |
+|---|---|---|
+| 第一阶段 | 三种方式完成基础联调 | P1-A~P1-H |
+| 第二阶段 | 集成回归平台 | P2-A~P2-F |
+
+第一阶段完成后先验收,再决定是否进入第二阶段。
+
+---
+
+## 3. 第一阶段拟新增和修改文件
+
+以下为初步清单,编码前须重新核对实际调用面。
+
+### 3.1 新增目录
+
+```text
+tools/integration-simulator/
+├─ app.py
+├─ requirements.txt
+├─ README.md
+├─ config.example.json
+├─ clients/
+│  ├─ inbound_client.py
+│  └─ aidop_probe.py
+├─ providers/
+│  ├─ mock_api.py
+│  └─ mock_db.py
+├─ catalog/
+│  ├─ entities.json
+│  ├─ scenarios.json
+│  └─ sample_adapters.py
+├─ static/
+│  ├─ index.html
+│  ├─ app.js
+│  └─ app.css
+└─ tests/
+   ├─ test_inbound_signature.py
+   ├─ test_sample_adapters.py
+   ├─ test_mock_api.py
+   ├─ test_mock_db.py
+   └─ test_redaction.py
+```
+
+### 3.2 复用但不迁移
+
+第一阶段继续复用:
+
+```text
+doc/db/mdp/mock_api/endpoints.json
+doc/db/mdp/mock_api/samples/*.json
+tools/mock/inbound/push_demo.py
+```
+
+不得直接删除或搬迁这些文件。新模拟器通过适配器读取既有样例,避免破坏现有 contract tests。
+
+### 3.3 可能修改
+
+仅在确认调用面后修改:
+
+```text
+doc/db/mdp/mock_api/mock_server.py
+doc/db/mdp/mock_api/requirements.txt
+doc/db/mdp/mock_api/seed_mock_db.py
+doc/plan/系统集成/README.md
+```
+
+原则:
+
+- `mock_server.py` 只做兼容复用,不改变现有端点默认响应;
+- `seed_mock_db.py` 不再只登记来源,需能调用新模拟库装载器或明确弃用;
+- 不修改 `Web/` 和 `server/` 主代码,除非实施中实证发现阻断缺陷并另行确认。
+
+---
+
+## 4. P1-A:公共模型与目录
+
+### 目标
+
+建立三种通道共用的对象目录、样例目录和状态模型。
+
+### 实施
+
+`catalog/entities.json` 每个对象至少包含:
+
+```json
+{
+  "objectCode": "ITEM",
+  "businessName": "物料基础信息",
+  "sample": "doc/db/mdp/mock_api/samples/item.json",
+  "dbSync": {
+    "supported": true,
+    "table": "sim_item",
+    "businessKey": ["Domain", "ItemNum"],
+    "incrementColumn": "sourceUpdatedAt"
+  },
+  "apiPull": {
+    "supported": true,
+    "path": "/api/items",
+    "responsePath": "data.list",
+    "dedupKeyPath": "bizKey"
+  },
+  "apiInbound": {
+    "supported": true,
+    "entityCode": "MDM_ITEM",
+    "contractVersion": "v1"
+  }
+}
+```
+
+状态枚举至少包括:
+
+```text
+CODE_UNSUPPORTED
+CONFIG_NOT_REGISTERED
+CONFIG_NOT_ENABLED
+GRANT_MISSING
+SOURCE_UNREACHABLE
+READY
+DATA_NOT_RUN
+RUNNING
+RUN_SUCCEEDED
+RUN_FAILED
+```
+
+### 范围
+
+对象目录必须覆盖业务页确认表全部数据包。API_INBOUND 侧登记当前 26 个代码契约;没有推送契约的对象写 `supported=false` 和明确原因。
+
+### 验收
+
+- 目录加载成功;
+- 每个业务数据包都有三通道状态;
+- 不把“配置未开”写成“代码不支持”;
+- 所有样例路径存在;
+- 样例不含真实客户敏感数据。
+
+---
+
+## 5. P1-B:安全配置与本地控制服务
+
+### 目标
+
+实现本地 FastAPI 控制服务与静态页面。
+
+### 配置
+
+`config.example.json` 只能放非敏感默认项:
+
+```json
+{
+  "aidopBaseUrl": "http://127.0.0.1:5005",
+  "mockApiPort": 8018,
+  "mysqlDatabase": "aidop_integration_sim",
+  "allowedTargetHosts": ["127.0.0.1", "localhost"]
+}
+```
+
+敏感信息仅从环境变量读取,例如:
+
+```text
+AIDOP_SIM_MYSQL_PASSWORD
+AIDOP_SIM_INBOUND_SECRET
+AIDOP_SIM_INBOUND_ACCESS_KEY
+AIDOP_SIM_ADMIN_TOKEN
+```
+
+不得提供含真实值的 `.env`。
+
+### 页面
+
+至少包含:
+
+- 环境状态;
+- 对象选择;
+- 通道选择;
+- 样例编辑;
+- 执行按钮;
+- 请求/响应预览;
+- 三层结果;
+- 最近运行记录。
+
+### 安全
+
+- 浏览器 API 不返回 Secret;
+- 服务端日志对认证头和连接串脱敏;
+- 默认拒绝非白名单目标 host;
+- 报告中只保留 AccessKey 后四位;
+- 页面不使用 localStorage 保存认证信息。
+
+### 验收
+
+- 本地页面可打开;
+- 缺环境变量时给出明确提示;
+- 日志无密码、Secret、JWT;
+- 非白名单目标被阻断。
+
+---
+
+## 6. P1-C:DB_SYNC 模拟
+
+### 目标
+
+模拟第三方 MySQL 数据库,供 Ai-DOP 现有 DB_SYNC 真实读取。
+
+### 实施
+
+`mock_db.py` 提供:
+
+1. 创建模拟 schema;
+2. 按对象建源表;
+3. 重置单表;
+4. 装载 canonical 样例;
+5. 追加数据;
+6. 更新已有数据并推进增量列;
+7. 查询样例行数与最大增量值;
+8. 生成 Ai-DOP 配置清单。
+
+不得:
+
+- 连接 Ai-DOP 库后直接写贴源;
+- 使用 Ai-DOP 主库账号作为模拟库账号;
+- 自动覆盖现有 `mdp_source` 或标准对象来源登记。
+
+### 最小对象
+
+必须覆盖至少:
+
+- 物料;
+- 销售订单头与行;
+- 生产工单;
+- 采购订单与收货;
+- 出入库流水;
+- 交货计划;
+- 工序计划;
+- 成品相关数据。
+
+最终以对象目录中 `dbSync.supported=true` 的全部对象为完成范围。
+
+### 用例
+
+1. 首次全量;
+2. 重复同步;
+3. 新增增量;
+4. 更新增量;
+5. 错误业务键;
+6. 增量列不推进;
+7. 源库不可达;
+8. 认证失败。
+
+### 验收
+
+- Ai-DOP 通过 `MdpDbPullExecutor` 拉取;
+- `mdp_sync_log` 有结果;
+- `last_cursor` 符合预期;
+- 贴源数据来源为测试 source_code;
+- 重复执行不产生重复业务行。
+
+---
+
+## 7. P1-D:API_PULL 模拟
+
+### 目标
+
+模拟第三方查询 API,供 Ai-DOP 现有 API_PULL 主动调用。
+
+### 实施
+
+`mock_api.py` 复用既有 endpoints 与 samples,增加统一控制能力:
+
+- 启停;
+- 切换样例;
+- cursor 过滤;
+- 鉴权模式;
+- 延迟;
+- HTTP 错误;
+- 响应路径;
+- 每次调用的脱敏审计。
+
+第一阶段不实现超出现有执行器能力的复杂分页协议。
+
+### 鉴权用例
+
+- NONE;
+- Bearer/Token;
+- Basic;
+- APIKey。
+
+OAuth2 只验证配置路径或放第二阶段,不在第一阶段搭完整授权服务器。
+
+### 响应用例
+
+- `data.list` 正常;
+- 空数组;
+- 错误 response path;
+- 重复 bizKey;
+- cursor 前后数据;
+- 401、403、429、500;
+- 超时。
+
+### 验收
+
+- Ai-DOP 通过 `MdpApiPullExecutor` 请求 Mock;
+- 认证头正确;
+- response path 与 dedup key 生效;
+- cursor 场景符合当前执行器行为;
+- 贴源 raw_data 与样例一致;
+- 错误响应不会被写成成功。
+
+---
+
+## 8. P1-E:API_INBOUND 模拟
+
+### 目标
+
+把现有 `push_demo.py` 的协议能力提取为可复用客户端,并由页面驱动。
+
+### 实施
+
+`inbound_client.py` 必须与后端一致:
+
+```text
+message =
+METHOD&path&accessKey&timestamp&nonce&bodySha256&idempotencyKey
+signature = Base64(HMAC-SHA256(secret, message))
+```
+
+支持 Headers:
+
+```text
+X-Access-Key
+X-Timestamp
+X-Nonce
+X-Signature
+X-Content-SHA256
+Idempotency-Key
+X-Mdp-Contract-Version
+```
+
+### 七类操作
+
+1. schema;
+2. 普通 POST;
+3. receipt;
+4. bulk;
+5. open snapshot;
+6. commit snapshot;
+7. digest。
+
+### 实体范围
+
+识别方案中列出的 26 个 entityCode。每个实体:
+
+- 可发起 schema;
+- 可编辑最小样例;
+- schema 成功时校验必填字段;
+- 403 时显示配置未开;
+- 普通 POST 只对 READY 实体执行。
+
+### 必测用例
+
+- 正常推数;
+- 缺幂等键;
+- body 摘要不一致;
+- 过期 timestamp;
+- nonce replay;
+- 同幂等键同 body;
+- 同幂等键不同 body;
+- 部分行拒绝;
+- stale 数据;
+- v2 库存流水;
+- bulk;
+- snapshot open/commit;
+- digest;
+- receipt。
+
+### 验收
+
+- 签名固定向量与 C# 端一致;
+- Secret 不出现在浏览器与日志;
+- 26 个实体都生成预检结果;
+- READY 实体普通推数成功;
+- 七类操作完成基础测试;
+- `mdp_inbound_request` 与回执一致。
+
+---
+
+## 9. P1-F:跨通道样例与基础业务场景
+
+### 目标
+
+让三种方式使用同一业务语义,而不是三套互不关联的 Demo。
+
+### 运行编号
+
+```text
+SIM-{yyyyMMddHHmmss}-{shortId}
+```
+
+所有业务键由适配器附加运行编号。
+
+### 基础场景一:订单制造链
+
+```text
+物料
+→ 销售订单头/行
+→ 生产工单
+→ 工单用料
+→ 报工
+```
+
+### 基础场景二:采购执行链
+
+```text
+供应商
+→ 采购订单
+→ 供应商发货
+→ 收货/IQC
+→ 退货
+```
+
+### 基础场景三:库存与成品链
+
+```text
+库存期初
+→ API_INBOUND v2 出入库流水
+→ 成品报检
+→ 成品入库/结存
+```
+
+### 关联键
+
+- 销售:订单号 + 行号;
+- 制造:工单号;
+- 采购:采购单号 + 行号 + 物料;
+- 库存:流水 Id + 物料 +业务类型;
+- 齐套:核验头 Id + 工单号;
+- 成品:生产订单号 + 销售订单号。
+
+### 验收
+
+- 三种通道的样例适配后保持业务含义一致;
+- 无契约节点明确显示“不支持该通道”;
+- 不因跳过节点宣称链路完整;
+- 样例不使用固定真实单号。
+
+---
+
+## 10. P1-G:预检、结果分层与报告
+
+### 预检
+
+每次执行前检查:
+
+- 模拟端是否可用;
+- Ai-DOP 是否可访问;
+- 来源是否存在;
+- 实体是否登记;
+- 目标贴源是否登记;
+- API_INBOUND 是否 enabled;
+- schema/grant 是否可用;
+- 最终目标租户;
+- 运行编号是否唯一。
+
+### 三层报告
+
+#### 代码层
+
+```text
+SUPPORTED / UNSUPPORTED
+```
+
+#### 配置层
+
+```text
+READY / NOT_REGISTERED / NOT_ENABLED / GRANT_MISSING / SOURCE_UNREACHABLE
+```
+
+#### 数据层
+
+```text
+NOT_RUN / ACCEPTED / STAGING_WRITTEN / TRANSFORM_PENDING / RUN_FAILED
+```
+
+第一阶段若没有安全的只读管理 API 查询贴源结果,允许报告 `TRANSFORM_PENDING` 并给出人工 SQL,不得猜测成功。
+
+### 报告内容
+
+- 运行编号;
+- 时间;
+- 对象;
+- 通道;
+- 请求摘要;
+- 脱敏响应;
+- 三层结论;
+- 后续核验 SQL;
+- 已知契约缺口。
+
+---
+
+## 11. P1-H:测试、文档与第一阶段验收
+
+### 单元测试
+
+- 签名 golden vector;
+- SHA256 与 HMAC;
+- 脱敏;
+- 样例适配;
+- 业务键前缀;
+- MySQL DDL 生成;
+- cursor 过滤;
+- 三层状态判定;
+- 非白名单目标阻断。
+
+### 集成测试
+
+1. 启动 Mock API;
+2. 初始化模拟 MySQL;
+3. 启动 Ai-DOP;
+4. 配置三个测试来源;
+5. 每种方式至少跑通一个金标对象;
+6. DB_SYNC 跑一次增量;
+7. API_PULL 跑一次鉴权和 cursor;
+8. API_INBOUND 跑 26 实体 schema 预检和七类操作;
+9. 执行三组基础场景;
+10. 生成脱敏报告。
+
+### 推荐命令
+
+具体命令以实现后的 README 为准,预期形式:
+
+```text
+python -m venv .venv
+.venv\Scripts\pip install -r tools/integration-simulator/requirements.txt
+.venv\Scripts\python tools/integration-simulator/app.py
+.venv\Scripts\pytest tools/integration-simulator/tests
+```
+
+### 第一阶段验收表
+
+- [ ] 三种通道都经过 Ai-DOP 真实执行器
+- [ ] MySQL 模拟库不等于 Ai-DOP 库
+- [ ] API_PULL Mock 可切鉴权与错误
+- [ ] API_INBOUND Secret 不进浏览器
+- [ ] 业务页全部数据包已登记能力矩阵
+- [ ] 26 个推数契约全部预检
+- [ ] 七类推数操作都有基础用例
+- [ ] 三组跨对象场景可执行
+- [ ] 配置未开与代码不支持分开显示
+- [ ] 运行报告已脱敏
+- [ ] 没有直接写 Ai-DOP 业务表
+- [ ] 没有改变生产标准对象来源
+
+---
+
+## 12. 第二阶段任务
+
+### P2-A:SQL Server 方言
+
+- 建立 SQL Server 模拟库;
+- 验证列探测;
+- 验证 OFFSET/FETCH;
+- 验证 keyset;
+- 对比 MySQL 与 SQL Server 序列化差异。
+
+### P2-B:场景编排
+
+- 依赖图;
+- 自动排序;
+- 多对象批次;
+- 场景回放;
+- 参数化业务日期与数量;
+- 场景版本管理。
+
+### P2-C:故障注入
+
+- 网络中断;
+- 超时;
+- 限流;
+- 重复;
+- 乱序;
+- cursor 不推进;
+- 脏业务键;
+- 快照差集阻断;
+- 大批量压力。
+
+### P2-D:全链路自动对账
+
+- 贴源;
+- 标准层;
+- DWD;
+- KPI;
+- 看板 API;
+- S8 规则输入;
+- ChatBI 可查询范围。
+
+### P2-E:CLI / CI / 容器化
+
+- 无界面运行;
+- JUnit/JSON 报告;
+- Docker Compose;
+- 每日契约回归;
+- Pull Request 守卫;
+- 契约漂移检测。
+
+### P2-F:Ai-DOP 契约扩展衔接
+
+对当前无 API_INBOUND 契约的对象逐项立任务:
+
+- 销售订单头;
+- 合同评审;
+- 发货计划/发货单;
+- 工序计划;
+- MRP;
+- 采购申请;
+- 交货计划;
+- 现存量;
+- 过程检验;
+- 成品入库。
+
+这些任务属于 Ai-DOP 产品能力建设,不应混入模拟器代码。
+
+---
+
+## 13. 反向影响推演
+
+### 13.1 代码调用面
+
+已核查:
+
+- DB_SYNC → `MdpSourcePullDispatcher` → `MdpDbPullExecutor`;
+- API_PULL → `MdpSourcePullDispatcher` → `MdpApiPullExecutor`;
+- API_INBOUND → `MdpInboundController` → 接收服务;
+- 三者最终进入现有贴源/转换路径。
+
+结论:**第一阶段模拟器本身无主链影响;真实测试会调用同步与重建链。**
+
+### 13.2 数据契约面
+
+已核查:
+
+- 三通道需统一业务键与业务语义;
+- 技术页字段是样例适配依据;
+- API_INBOUND 只有 26 个现有代码契约;
+- 周转类推数依赖库存流水 v2。
+
+结论:**需同步新增对象目录与样例适配器;不得改现有指标口径。**
+
+### 13.3 多数据源面
+
+已核查:
+
+- 同一 standard object 可能已有 T8、165、自建单或真实第三方来源;
+- 三种模拟通道不能共用 source_code;
+- 测试来源不能成为生产权威。
+
+结论:**需三个专用测试来源;运行前显示来源决策。**
+
+### 13.4 多租户 / Domain 面
+
+已核查:
+
+- API_INBOUND 租户取开放身份;
+- DB/API 拉取取来源与实体配置;
+- Domain 不能代替 tenant_id。
+
+结论:**只允许测试租户;需在预检中显示租户和 Domain。**
+
+### 13.5 运行面
+
+已核查:
+
+- 模拟器依赖 Python 与 MySQL;
+- Mock API 现有默认端口为 8018;
+- 测试会占用同步、重建、限流与 nonce 资源;
+- 第一阶段不应做高并发压测。
+
+结论:**需低频批次限制、端口检测和生产目标硬阻断。**
+
+### 13.6 验证面
+
+已核查:
+
+- 现有 push demo 有 10 类推数用例;
+- 现有 mock_api 有大量对象样例;
+- 模拟数据库缺完整物化;
+- 没有统一三层报告。
+
+结论:**需补模拟数据库、统一控制、状态分层与守卫测试。**
+
+---
+
+## 14. 版本与提交规则
+
+### 本次文档交付
+
+只新增/修改说明文档,不递增前后端版本。
+
+### 后续第一阶段代码
+
+若只改 `tools/`、`doc/db/mdp/mock_api/` 和文档,且不进入前后端运行构建:
+
+- 不递增 `Web/package.json`;
+- 不递增 server `.csproj`。
+
+若实施中修改 `Web/` 或 `server/` 运行代码/配置:
+
+- 按实际纳入提交的端递增 patch;
+- 前后端同时改则分别递增;
+- 不得因为计划涉及某端但提交未包含该端而升号。
+
+建议提交拆分:
+
+1. `feat(simulator): 建立三通道公共目录与本地控制台`
+2. `feat(simulator): 增加 MySQL 与 API_PULL 模拟`
+3. `feat(simulator): 接入 API_INBOUND 全协议测试`
+4. `test(simulator): 增加三通道场景与脱敏报告守卫`
+
+用户说“提交”时按仓库规则 commit + push。
+
+---
+
+## 15. 交付报告模板
+
+### 版本与提交
+
+- 模拟器版本:
+- commits:
+- 是否修改 Web/server:
+
+### 三层结果
+
+#### 代码层
+
+- DB_SYNC:
+- API_PULL:
+- API_INBOUND:
+
+#### 配置层
+
+- 测试租户:
+- 三个 source_code:
+- 已登记对象:
+- 未登记/未授权对象:
+
+#### 数据层
+
+- DB_SYNC 流水:
+- API_PULL 贴源:
+- API_INBOUND request/receipt:
+- 标准层/KPI:已核验或待核验:
+
+### 对象覆盖
+
+- 业务页数据包:
+- API_INBOUND 26 契约:
+- 无推送契约对象:
+
+### 安全
+
+- Secret 未落盘:
+- 报告已脱敏:
+- 生产地址已阻断:
+
+### 反向影响
+
+- 调用面:
+- 契约面:
+- 多来源:
+- 多租户:
+- 运行:
+- 验证:
+
+### 未完成项
+
+- 阻塞:
+- 第二阶段候选:
+
+---
+
+## 16. 后续执行者启动提示词
+
+```text
+请严格执行 doc/plan/系统集成/Ai-DOP第三方对接模拟器初步执行任务书.md 的第一阶段。
+
+开工前:
+1. 读取 AGENTS.md 和全部 alwaysApply 规则;
+2. 重跑任务书 G3 的代码/配置/数据三层基线;
+3. 列出实际改动文件、可见行为、风险和不做范围,等待确认;
+4. 不得提交 Word 锁文件、日志、凭据、连接串或真实业务数据。
+
+第一阶段必须同时覆盖 DB_SYNC、API_PULL、API_INBOUND。
+不得绕过 Ai-DOP 执行器直接写贴源/标准/KPI 表。
+不得把配置未开通说成代码未实现。
+不得把 HTTP 成功说成业务全链路已跑通。
+```

+ 700 - 0
doc/plan/系统集成/Ai-DOP第三方对接模拟器建设方案.md

@@ -0,0 +1,700 @@
+# Ai-DOP 第三方对接模拟器建设方案
+
+| 项 | 内容 |
+|---|---|
+| 文档定位 | 第三方对接模拟器总体方案 |
+| 编制日期 | 2026-09-30 |
+| 状态 | 初版方案,待按配套任务书实施 |
+| 业务依据 | [`Ai-DOP第三方对接业务数据详表-业务页.md`](./Ai-DOP第三方对接业务数据详表-业务页.md) |
+| 技术依据 | [`Ai-DOP第三方对接业务数据详表-技术页.md`](./Ai-DOP第三方对接业务数据详表-技术页.md)、[`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md) |
+| 配套任务书 | [`Ai-DOP第三方对接模拟器初步执行任务书.md`](./Ai-DOP第三方对接模拟器初步执行任务书.md) |
+
+---
+
+## 0. 一句话
+
+建设一个独立运行的本地第三方系统模拟器,用同一套可追踪业务样例分别模拟:
+
+1. 第三方数据库供 Ai-DOP 同步,即 `DB_SYNC`;
+2. 第三方查询 API 供 Ai-DOP 主动拉取,即 `API_PULL`;
+3. 第三方主动调用 Ai-DOP 标准接口推数,即 `API_INBOUND`。
+
+第一阶段以“能真实完成三种对接方式的基础联调”为目标;第二阶段再补场景编排、故障注入、自动对账、更多数据库方言与持续回归。
+
+---
+
+## 1. 背景与问题
+
+Ai-DOP 已具备三种入站方式,但当前联调资产分散:
+
+- API 主动拉取 Mock 位于 `doc/db/mdp/mock_api/`;
+- API 推数客户端位于 `tools/mock/inbound/push_demo.py`;
+- 数据库模拟脚本只登记过 Mock 来源,尚未形成完整的“样例 → 源表 → 同步 → 验收”工具;
+- 各工具没有统一页面、统一样例目录、统一运行编号和统一结果解释;
+- 接口成功、配置开通、数据真正进入贴源与标准层经常被混成一个结论。
+
+因此,测试人员需要在多个脚本、配置页和数据库查询之间来回切换,难以快速回答:
+
+- 当前失败是模拟端没启动、Ai-DOP 代码不支持、配置没开通,还是数据未跑通?
+- 同一业务对象用数据库同步、接口拉取、接口推送时,最终是否落到一致的数据语义?
+- HTTP 202 是否只是接收成功,还是下游贴源、转换和指标也成功?
+- 当前业务数据详表中的对象,哪些能通过哪种方式验证?
+
+---
+
+## 2. 建设目标
+
+### 2.1 第一阶段目标:基础联调可用
+
+第一阶段交付一个独立本地可视化工具,做到:
+
+1. 启停第三方 Mock API;
+2. 初始化并装载第三方 MySQL 模拟源库;
+3. 使用真实签名调用 Ai-DOP API_INBOUND;
+4. 三种方式共用同一套 canonical 业务样例;
+5. 按业务对象查看请求、响应、耗时、运行编号与可解释结果;
+6. 能验证业务页、技术页中各数据包在三种方式下的可达性;
+7. 明确区分“代码无能力”“配置未开通”“数据未跑通”;
+8. 不接触生产凭据,不把测试数据伪装成真实业务数据。
+
+第一阶段不是完整自动化测试平台,不要求把所有标准层、KPI 和看板对账全部自动化。
+
+### 2.2 第二阶段目标:可重复的集成回归平台
+
+第二阶段在第一阶段基础上增加:
+
+- 跨对象业务场景编排;
+- 错误签名、超时、重复、乱序、分页、断点续传等故障注入;
+- 标准层、DWD、KPI、看板结果自动对账;
+- SQL Server 数据库方言回归;
+- CLI/CI 无界面执行;
+- 报告导出、历史对比与脱敏留证;
+- Docker 化与一键环境初始化;
+- 对当前无 API_INBOUND 契约的业务对象,在 Ai-DOP 先完成契约扩展后纳入推数回归。
+
+---
+
+## 3. 明确不做
+
+### 3.1 第一阶段不做
+
+- 不模拟 Ai-DOP 向第三方回写的出站接口;
+- 不直接写 Ai-DOP 贴源表、标准表、DWD 或 KPI 表;
+- 不绕过现有 `MdpDbPullExecutor`、`MdpApiPullExecutor`、`MdpInboundController`;
+- 不为不存在的 API_INBOUND 对象伪造接口;
+- 不自动修改生产环境的数据源、实体、授权或开放身份;
+- 不持久化 AccessSecret、数据库密码、JWT、连接串;
+- 不以 HTTP 200/202 单独宣称“业务已跑通”;
+- 不在第一阶段同时模拟 MySQL、SQL Server、Oracle、PostgreSQL 四种数据库。
+
+### 3.2 第二阶段另立项的 Ai-DOP 能力缺口
+
+下列对象当前没有 API_INBOUND 推送契约,模拟器不能代替 Ai-DOP 实现它们:
+
+- 销售订单头;
+- 合同评审环节;
+- 发货计划与发货单;
+- 工序级日计划与排产结果;
+- 物料需求计划;
+- 采购申请;
+- 交货计划;
+- 现存量快照;
+- 过程检验;
+- 成品入库。
+
+它们在第一阶段仍可通过 `DB_SYNC` 或 `API_PULL` 模拟;若要支持第三方主动推送,须先完成 Ai-DOP 契约与贴源配置扩展。
+
+---
+
+## 4. 现状取证
+
+能力结论必须分代码层、配置层、数据层。
+
+### 4.1 代码层
+
+#### 数据库同步
+
+- 执行器:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPullExecutor.cs`;
+- 实施名:`DB_SYNC`;
+- 连接工厂:`MdpSourceScopeFactory.cs`;
+- 代码支持 MySQL、SQL Server、Oracle、PostgreSQL;
+- 增量依赖 `mdp_entity.incr_column` 与 `last_cursor`;
+- MySQL 与 SQL Server 有不同分页 SQL。
+
+结论:代码层已有数据库同步能力。第一阶段用 MySQL 覆盖主路径,第二阶段补 SQL Server 方言回归。
+
+可重跑检索:
+
+```text
+rg "SupportedType.*DB_SYNC|class MdpDbPullExecutor|MapDbType" server/Plugins/Admin.NET.Plugin.AiDOP
+```
+
+#### 主动接口拉取
+
+- 执行器:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpApiPullExecutor.cs`;
+- 实施名:`API_PULL`;
+- 当前采用 GET;
+- 支持 `NONE`、Bearer/Token、Basic、APIKey、OAuth2 等鉴权配置;
+- 支持 `response_data_path`、`dedup_key_path`;
+- 增量模式会附加 cursor 参数;
+- 当前不是通用多页循环客户端,Mock 必须按现有契约一次返回本批数据。
+
+结论:代码层已有 API 拉取能力,现有 `doc/db/mdp/mock_api/mock_server.py` 可作为基础。
+
+可重跑检索:
+
+```text
+rg "class MdpApiPullExecutor|response_data_path|dedup_key_path|cursor" server/Plugins/Admin.NET.Plugin.AiDOP
+```
+
+#### 第三方接口推送
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs` 已提供:
+
+1. `POST /api/mdp/inbound/{entityCode}`;
+2. `GET /api/mdp/inbound/{entityCode}/schema`;
+3. `GET /api/mdp/inbound/receipts/{syncBatchId}`;
+4. `POST /api/mdp/inbound/{entityCode}/snapshots`;
+5. `POST /api/mdp/inbound/{entityCode}/snapshots/{snapshotId}/commit`;
+6. `POST /api/mdp/inbound/{entityCode}/bulk`;
+7. `GET /api/mdp/inbound/{entityCode}/digest`。
+
+认证使用 `InboundSignature`,签名字串为:
+
+```text
+METHOD&path&accessKey&timestamp&nonce&bodySha256&idempotencyKey
+```
+
+结论:代码层已有统一推数、验签、幂等、回执、快照、bulk 和 digest 能力。
+
+可重跑检索:
+
+```text
+rg "Http(Post|Get)|InboundSignature|Idempotency-Key" server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound
+```
+
+### 4.2 配置层
+
+#### DB_SYNC / API_PULL
+
+必须存在:
+
+- `mdp_source`:来源类型、连接方式、数据库或 API 配置;
+- `mdp_entity`:源表/API path、目标贴源表、业务键、增量列、批次参数;
+- 租户标准对象来源登记;
+- 触发该对象拉取的模块重建或同步入口。
+
+#### API_INBOUND
+
+必须同时满足:
+
+1. 代码有字段契约;
+2. `mdp_entity.inbound_enabled=1`;
+3. `mdp_inbound_grant` 有当前 AccessKey 与 entityCode 授权;
+4. `SysOpenAccess` 有同 AccessKey 的开放身份和绑定租户。
+
+当前迁移脚本曾为联调来源准备最多 20 个实体授权,但运行环境不得仅凭脚本推定已开通。模拟器必须通过 schema 与预检结果实测。
+
+### 4.3 数据层
+
+本次只读查询:
+
+```sql
+SELECT status, COUNT(*)
+FROM mdp_inbound_request
+GROUP BY status;
+```
+
+返回空结果集,说明当前目标库尚无可分组的入站请求流水。该结论只表示数据未真实跑过,不表示代码没有能力。
+
+DB_SYNC / API_PULL 的真实运行情况需在实施前重跑:
+
+```sql
+SELECT source_code, status, COUNT(*) AS cnt,
+       MAX(end_time) AS latest_end
+FROM mdp_sync_log
+GROUP BY source_code, status
+ORDER BY source_code, status;
+```
+
+并按目标贴源表检查来源分布、最新业务日期和样例业务键。
+
+---
+
+## 5. 总体架构
+
+```mermaid
+flowchart LR
+  subgraph simulator [第三方对接模拟器]
+    ControlPanel[本地可视化控制台]
+    SampleCatalog[统一业务样例目录]
+    MockDb[(MySQL模拟源库)]
+    MockApi[第三方查询API]
+    InboundClient[第三方签名推数客户端]
+    RunStore[本地脱敏运行记录]
+    ControlPanel --> SampleCatalog
+    ControlPanel --> MockDb
+    ControlPanel --> MockApi
+    ControlPanel --> InboundClient
+    ControlPanel --> RunStore
+  end
+
+  subgraph aidop [Ai-DOP]
+    DbExecutor[MdpDbPullExecutor]
+    ApiExecutor[MdpApiPullExecutor]
+    InboundApi[MdpInboundController]
+    Staging[(mdp_stg贴源)]
+    Transform[标准层转换与模块重建]
+  end
+
+  MockDb -->|DB_SYNC| DbExecutor
+  MockApi -->|API_PULL| ApiExecutor
+  InboundClient -->|API_INBOUND| InboundApi
+  DbExecutor --> Staging
+  ApiExecutor --> Staging
+  InboundApi --> Staging
+  Staging --> Transform
+```
+
+### 5.1 独立工具而非 Ai-DOP 内置页面
+
+选择独立工具的原因:
+
+- 被测系统与模拟第三方系统边界清晰;
+- 能真实验证网络、鉴权与协议,不与 Ai-DOP 登录态混用;
+- Secret 可留在本地服务进程内,不进入浏览器;
+- 可同时提供数据库、HTTP 查询 API、签名推数三类外部能力;
+- 后续可独立容器化或交付第三方现场使用;
+- 不新增 Ai-DOP 业务菜单和 FUNC 编号。
+
+### 5.2 技术形态
+
+第一阶段优先使用 Python 实现,复用现有 `push_demo.py` 和 `mock_server.py` 的协议代码:
+
+```text
+tools/integration-simulator/
+├─ app.py                    # 本地控制服务与页面
+├─ clients/
+│  ├─ inbound_client.py      # HMAC 签名与七类推数操作
+│  └─ aidop_probe.py         # 只读预检
+├─ providers/
+│  ├─ mock_api.py            # API_PULL 对端
+│  └─ mock_db.py             # MySQL 建表、装载、清理
+├─ catalog/
+│  ├─ entities.json          # 对象与三通道能力矩阵
+│  └─ samples/*.json         # canonical 样例
+├─ static/                   # 本地页面资源
+├─ tests/
+└─ README.md
+```
+
+不要求另起完整 Vue/npm 工程。页面只承担配置、编辑样例、发起操作和显示结果。
+
+---
+
+## 6. 三种方式的第一阶段设计
+
+### 6.1 DB_SYNC
+
+#### 模拟端
+
+- 使用独立 MySQL schema,例如 `aidop_integration_sim`;
+- 按 canonical 样例生成第三方源表;
+- 每张表必须有稳定业务键;
+- 增量对象必须有单调递增时间列;
+- 支持重置、装载、追加、更新四种动作;
+- 所有业务键以 `SIM-{runId}-` 开头;
+- 模拟库只充当第三方源库,不写 Ai-DOP 库。
+
+#### Ai-DOP 侧前置
+
+- `mdp_source.source_type=DB`;
+- `conn_mode=EXTERNAL`;
+- 配置测试数据库连接;
+- `mdp_entity.source_table_name`、`target_table_name`、`incr_column`、业务键正确;
+- 按既有模块入口触发同步或重建。
+
+#### 基础验收
+
+- 连接测试成功;
+- 首次同步写入贴源;
+- 再次同步不重复;
+- 更新增量列后只处理新增/更新数据;
+- `mdp_sync_log` 有可解释状态;
+- 贴源行 `source_system` 为专用模拟来源。
+
+### 6.2 API_PULL
+
+#### 模拟端
+
+- 提供 GET 接口;
+- 支持 `data.list` 响应路径;
+- 每行带稳定 `bizKey` 或配置对应去重路径;
+- 支持 cursor 入参;
+- 支持 NONE、Bearer/Token、Basic、APIKey 的基础联调;
+- 可返回 200、401、403、429、500 和延迟响应;
+- 第一阶段每次返回完整当前批,不实现复杂多页循环。
+
+#### Ai-DOP 侧前置
+
+- `mdp_source.source_type=API`;
+- 配置 base URL 与鉴权;
+- `mdp_entity.source_api_path`;
+- `response_data_path`;
+- `dedup_key_path`;
+- 增量对象配置 cursor 语义。
+
+#### 基础验收
+
+- Ai-DOP 能带正确认证调用 Mock;
+- 能从 `data.list` 取行;
+- 去重键稳定;
+- cursor 能推进;
+- 鉴权失败和服务端错误有可解释结果;
+- 贴源数据与 canonical 样例一致。
+
+### 6.3 API_INBOUND
+
+#### 模拟端
+
+- 本地服务读取 AccessKey 与 Secret;
+- Secret 只驻留服务端内存;
+- 自动生成 timestamp、nonce、body SHA256、幂等键和 HMAC 签名;
+- 支持契约版本头;
+- 支持全部七类入站操作;
+- 允许编辑 JSON 或 NDJSON;
+- 支持重复发送相同幂等键;
+- 不在页面或运行日志显示 Secret。
+
+#### Ai-DOP 侧前置
+
+- 开放身份已绑定测试租户;
+- grant 与 entityCode 已授权;
+- entity 已开启 inbound;
+- 来源为专用 `API_INBOUND` 测试来源;
+- 限流与 IP 白名单允许本机测试。
+
+#### 基础验收
+
+- schema 可读;
+- 普通推数返回 202;
+- receipt 可按批次查询;
+- 相同幂等键同报文返回幂等回放;
+- 相同幂等键不同报文返回冲突;
+- 错误签名、过期时间戳、nonce 重放被拒;
+- bulk、快照和 digest 可完成基础成功用例;
+- `mdp_inbound_request` 与贴源数据可对账。
+
+---
+
+## 7. 业务范围
+
+### 7.1 统一业务样例目录
+
+样例目录以业务页第 5 章确认表和技术页字段为基线,至少覆盖:
+
+- 物料、客户、供应商、货源清单、库位、员工;
+- 销售订单头、销售订单行、合同评审、齐套核验;
+- 生产工单、工序计划、工单用料、报工;
+- 交货计划、采购订单、采购申请、收货、供应商发货、来料检验、退货、欠料;
+- 出入库流水、库存期初、月度库存金额、现存量;
+- 成品报检、成品入库、成品结存;
+- 业务页明确要求的关联日期、数量、状态、业务类型、审核状态。
+
+同一场景中的订单号、工单号、采购单号、物料编码必须可关联。
+
+### 7.2 API_INBOUND 当前 26 个代码契约
+
+第一阶段模拟器须识别:
+
+```text
+MDM_ITEM
+MDM_CUSTOMER
+MDM_SUPPLIER
+MDM_LOCATION
+MDM_SOURCE_LIST
+MDM_EMPLOYEE_HEADCOUNT
+S1_SALES_ORDER_ENTRY
+S1_REQUIREMENT_EXAMINE_RESULT
+S1_REQUIREMENT_EXAMINE_DETAIL
+S2_WORK_ORDER_SCHEDULE
+S3_PURCHASE_ORDER
+S3_PURCHASE_RECEIPT
+S4_SHIPMENT
+S4_IQC
+S4_RETURN
+S4_SHORTAGE
+S5_WORK_ORDER_BOM
+S5_INVENTORY_TXN
+S5_INVENTORY_OPENING_BALANCE
+S5_INVENTORY_BALANCE_MONTHLY
+S6_WORK_ORDER_LINE
+S6_REPORT_TXN
+S7_FQC_TASK_TXN
+S7_SALES_ORDER_LINE
+S7_FINISHED_ONHAND
+S7_FINISHED_OPENING_BALANCE
+```
+
+支持某 entityCode 不等于运行配置已开通。预检结果必须分别显示:
+
+- `CODE_SUPPORTED`;
+- `CONFIG_NOT_REGISTERED`;
+- `CONFIG_NOT_ENABLED`;
+- `GRANT_MISSING`;
+- `READY`;
+- `DATA_NOT_RUN`;
+- `RUN_SUCCEEDED`;
+- `RUN_FAILED`。
+
+### 7.3 通道能力矩阵
+
+| 对象类型 | DB_SYNC | API_PULL | API_INBOUND |
+|---|:---:|:---:|:---:|
+| 技术页已开通的 20 个推数对象 | 可模拟 | 可模拟 | 可模拟 |
+| 客户/供应商/库位/货源 | 可模拟 | 可模拟 | 代码有契约,配置可能未开 |
+| 齐套结果头/明细 | 可模拟 | 可模拟 | 代码有契约,贴源配置待核 |
+| 销售订单头/合同评审 | 可模拟 | 可模拟 | 当前无契约 |
+| 交货计划/MRP/采购申请 | 可模拟 | 可模拟 | 当前无契约 |
+| 工序计划/排产 | 可模拟 | 可模拟 | 当前无契约 |
+| 现存量/过程检验/成品入库 | 可模拟 | 可模拟 | 当前无契约 |
+
+### 7.4 第一阶段跨对象基础场景
+
+至少提供三组样例:
+
+1. **订单制造链**:物料 → 销售订单 → 工单 → 工单用料 → 报工;
+2. **采购执行链**:供应商 → 采购订单 → 供应商发货 → 收货/IQC → 退货;
+3. **库存与成品链**:期初 → v2 出入库流水 → 成品报检 → 成品结存。
+
+API_INBOUND 无对应契约的节点必须在运行图中显示“该通道不支持”,不能跳过后仍宣称全链路完整。
+
+---
+
+## 8. 页面设计
+
+### 8.1 首页
+
+显示:
+
+- Ai-DOP 目标地址;
+- 模拟数据库状态;
+- Mock API 状态;
+- API_INBOUND 凭证是否已加载;
+- 三种方式的可用对象数量;
+- 最近运行结果。
+
+### 8.2 对象测试页
+
+用户选择:
+
+- 业务对象;
+- 对接方式;
+- 样例;
+- 运行编号;
+- 契约版本;
+- 操作类型。
+
+页面展示:
+
+- 请求/源数据预览;
+- 预检结果;
+- 执行结果;
+- 脱敏 Headers;
+- HTTP 状态和响应;
+- 后续人工核验提示;
+- 明确的代码/配置/数据层结论。
+
+### 8.3 环境安全页
+
+- 只允许 `localhost`、明确的开发/UAT 地址;
+- 生产目标默认阻断;
+- Secret 只从环境变量或启动输入读取;
+- 页面不可读取 Secret;
+- 运行记录自动脱敏;
+- 不提供“保存密码”按钮。
+
+---
+
+## 9. 安全与数据隔离
+
+1. 测试数据业务键统一加 `SIM-{runId}-`;
+2. 默认只允许测试租户;
+3. 三种通道使用不同 `source_code`,共享一个测试 `system_code`;
+4. 模拟数据库与 Ai-DOP 数据库必须是不同 schema/连接;
+5. 不在 Git 中写入任何密码、Secret、JWT 或真实连接串;
+6. 导出报告不含认证头、Secret、数据库密码和完整敏感 payload;
+7. 删除测试数据不由模拟器直接执行,第一阶段只提供运行编号和清理清单;
+8. API_INBOUND 不接受 body 中任意 tenantId 改变目标租户;
+9. 生产 URL 必须显式传入二次解锁参数方可调用,且第一阶段验收禁止使用生产。
+
+---
+
+## 10. 两阶段交付
+
+### 10.1 第一阶段:基础实现
+
+交付:
+
+- 独立本地控制台;
+- MySQL 模拟源库;
+- 第三方 Mock GET API;
+- API_INBOUND 签名客户端;
+- 统一样例目录;
+- 三通道对象能力矩阵;
+- 三种方式的基础成功/失败用例;
+- API_INBOUND 26 契约与 7 类操作;
+- 三组跨对象基础场景;
+- 脱敏运行记录;
+- 启动与配置说明;
+- 人工 SQL/API 验收清单。
+
+完成条件:
+
+- 任一具备配置的对象可选择三种方式之一执行;
+- 同一 canonical 样例可供三种方式转换使用;
+- 三条路径均经过 Ai-DOP 真实执行器;
+- 结果能明确定位到代码、配置或数据层;
+- 不依赖修改 Ai-DOP 业务代码即可完成基础联调。
+
+### 10.2 第二阶段:增强
+
+交付:
+
+- SQL Server 模拟库;
+- 场景编排与依赖自动排序;
+- 并发、限流、超时、乱序、断点、重试和错误注入;
+- 自动查询标准层、DWD、KPI 与看板 API;
+- 三通道同业务结果差异报告;
+- 历史报告与趋势;
+- CLI/CI;
+- Docker Compose;
+- Playwright 页面回归;
+- 契约漂移检测;
+- 当前无 API_INBOUND 契约对象的扩展项目衔接。
+
+---
+
+## 11. 验收口径
+
+### 11.1 代码层验收
+
+- DB_SYNC 确实经过 `MdpDbPullExecutor`;
+- API_PULL 确实经过 `MdpApiPullExecutor`;
+- API_INBOUND 确实经过 `MdpInboundController`;
+- 未新增直接写 Ai-DOP 业务表的旁路;
+- 签名算法有固定向量测试;
+- canonical 样例到三种通道的适配有单元测试。
+
+### 11.2 配置层验收
+
+- 每个对象显示来源、实体、目标贴源、业务键、增量列;
+- API_INBOUND 显示 entity 是否启用及 grant 是否可用;
+- 配置未开时明确停止,不自动改生产配置;
+- 同一标准对象不会误把三种来源同时设为生产权威。
+
+### 11.3 数据层验收
+
+- DB_SYNC:`mdp_sync_log`、游标、贴源来源分布可对账;
+- API_PULL:贴源 raw_data、业务键、cursor 可对账;
+- API_INBOUND:request、receipt、accepted/rejected、贴源可对账;
+- 三种方式都能查到最新业务日期;
+- “未产生数据”不能写成“代码未实现”。
+
+### 11.4 第一阶段完成判据
+
+第一阶段完成不等于所有 KPI 已有值。完成判据是:
+
+1. 三种入站方式都至少有一个对象真实跑通;
+2. 业务/技术详表的全部数据包都有通道能力标记;
+3. API_INBOUND 的已存在 26 个代码契约都能执行 schema 预检;
+4. 已配置的推数实体能执行普通 POST;
+5. 七类推数操作均有基础测试;
+6. 未配置或无契约对象能得到正确的分层结论;
+7. 三组基础业务场景可以装载和执行;
+8. 凭据与测试数据隔离要求全部满足。
+
+---
+
+## 12. 反向影响推演
+
+### 12.1 代码调用面
+
+- DB_SYNC 会经过所有调用 `MdpSourcePullDispatcher` 的模块同步/重建入口;
+- API_PULL 与 DB_SYNC 共用分发和贴源写入;
+- API_INBOUND 进入接收、镜像、中立投影和模块重建;
+- 第一阶段不修改这些主链,只新增独立工具。
+
+结论:**无主链代码改动;真实模拟流量会触发既有同步和重建。**
+
+### 12.2 数据契约面
+
+- 三种方式最终写同类贴源与标准对象;
+- 错误业务键可能覆盖同来源旧数据;
+- 日期、数量、业务类型、审核状态缺失会导致 HTTP 成功但指标无值;
+- `S5_INVENTORY_TXN` 周转类场景必须使用 v2 中立契约。
+
+结论:**需同步维护统一样例目录和字段映射;业务键必须测试隔离。**
+
+### 12.3 多数据源面
+
+- 三种来源不能用同一 `source_code`;
+- 同一标准对象生产时只能有一个权威来源;
+- 模拟运行只能使用测试来源,不得改变现有 T8、165、自建单或真实第三方来源。
+
+结论:**需三个测试 source_code 和共同 system_code;不得抢占生产来源。**
+
+### 12.4 多租户 / Domain 面
+
+- API_INBOUND 租户由开放身份决定;
+- DB_SYNC/API_PULL 按来源、实体和租户来源登记决定;
+- Domain 是业务域字段,不得替代 tenant_id;
+- canonical 样例必须可配置 Domain,但不能由此跨租户。
+
+结论:**只允许测试租户;运行前必须显示最终目标租户。**
+
+### 12.5 运行面
+
+- 需要本地 MySQL、Python Mock 服务和 Ai-DOP 开发/UAT 环境;
+- 真实请求会占用限流、nonce、同步任务和重建队列;
+- 大批量运行可能影响共享开发库;
+- 生产地址和凭据必须硬阻断。
+
+结论:**第一阶段默认低频单批,批量压测放第二阶段。**
+
+### 12.6 验证面
+
+- 现有 `push_demo.py` 仅覆盖推数部分场景;
+- 现有 `mock_api` 可复用但缺统一控制与结果分层;
+- 模拟 DB 尚缺完整样例物化;
+- 当前缺三通道同样例自动比较。
+
+结论:**第一阶段补基础守卫与人工对账,第二阶段补全链自动对账。**
+
+---
+
+## 13. 风险与控制
+
+| 风险 | 控制 |
+|---|---|
+| 测试数据污染真实业务 | 专用租户、来源、`SIM-{runId}` 业务键 |
+| Secret 泄漏 | 只在本地服务内存,日志和报告脱敏 |
+| 配置没开被误判为代码缺失 | 三层状态码与 schema 预检 |
+| HTTP 成功被误判为业务出数 | 第一阶段报告分“摄取成功”和“下游待核” |
+| 三种来源相互覆盖 | 不同 source_code,共同 system_code,仅测试来源 |
+| 大量数据触发重建压力 | 第一阶段单批低频,默认限制行数 |
+| API_PULL Mock 超出执行器能力 | 严格遵循现有 GET、data path、cursor 契约 |
+| MySQL 单引擎不能代表 SQL Server | 明确列为第二阶段方言回归 |
+
+---
+
+## 14. 决策结论
+
+1. 模拟器作为独立工具建设,不进入 Ai-DOP 菜单;
+2. 第一阶段同时覆盖 DB_SYNC、API_PULL、API_INBOUND;
+3. 第一阶段数据库模拟只做 MySQL,SQL Server 放第二阶段;
+4. 三种方式共用 canonical 样例,但保持独立 source_code;
+5. API_INBOUND 覆盖现有 26 个代码契约与 7 类操作;
+6. 业务页中无推送契约的对象仍通过 DB_SYNC/API_PULL 测试;
+7. 第一阶段不改 Ai-DOP 业务主链,不自动写生产配置;
+8. 方案实施以配套初步任务书为准。

+ 7 - 0
doc/plan/系统集成/README.md

@@ -16,6 +16,13 @@
 python doc/plan/系统集成/_gen_integration_guide_docx.py
 ```
 
+## 第三方对接模拟与测试(内部)
+
+| 文档 | 说明 |
+|------|------|
+| [Ai-DOP第三方对接模拟器建设方案.md](./Ai-DOP第三方对接模拟器建设方案.md) | 独立本地第三方系统模拟器总体方案;第一阶段同时验证数据库同步 `DB_SYNC`、主动接口拉取 `API_PULL`、第三方接口推送 `API_INBOUND`,第二阶段扩展全链路自动对账、故障注入、SQL Server 与 CI |
+| [Ai-DOP第三方对接模拟器初步执行任务书.md](./Ai-DOP第三方对接模拟器初步执行任务书.md) | 可供后续开发者或大模型执行的两阶段初步任务书;含三层基线、文件清单、工作包、26 个推数契约、7 类推数操作、测试门禁和六面反向影响 |
+
 ## 中立标准层改造(本目录,内部)
 
 | 文档 | 说明 |

+ 2 - 25
server/Admin.NET.Application/Configuration/Database.json

@@ -52,32 +52,9 @@
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)
           "EnableIncreSeed": false // 启用种子增量更新(只更新贴了特性[IncreSeed]的种子表)
         }
-      },
-      // T8 源库(SQL Server 只读)— S5/S6/S7 KPI 数据贴源同步用
-      // 接入边界:只读 SELECT;只在 refresh 作业里调用;禁止看板实时查 T8;禁止高频调度
-      // 所有 EnableInit*/EnableIncre* 强制 false:外部源库,DOP 不得对 T8 做任何 DDL / 种子写入
-      // 凭据来源:CLAUDE.md 「T8 源库连接信息(S5 / S6 / S7 KPI 对接用)」节
-      {
-        "ConfigId": "t8_v5",
-        "DbType": "SqlServer",
-        "DbNickName": "T8 源库(只读)",
-        "ConnectionString": "Server=39.105.125.212,29289;Database=t8_V5_09_56;User Id=dopsa;Password=&erp@2013!14.;TrustServerCertificate=true;Encrypt=false;Pooling=true;Min Pool Size=0;Max Pool Size=10;Connect Timeout=15;",
-        "DbSettings": {
-          "EnableInitDb": false, // T8 是外部源库,禁止 DOP 端建库
-          "EnableInitView": false, // 禁止 DOP 端建视图
-          "EnableDiffLog": false,
-          "EnableUnderLine": false,
-          "EnableConnEncrypt": false
-        },
-        "TableSettings": {
-          "EnableInitTable": false, // 禁止 DOP 端对 T8 做表结构 CodeFirst
-          "EnableIncreTable": false
-        },
-        "SeedSettings": {
-          "EnableInitSeed": false,
-          "EnableIncreSeed": false
-        }
       }
+      
+      
       //// 日志独立数据库配置
       //{
       //  "ConfigId": "1300000000002", // 日志库标识-禁止修改

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

@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.592</AssemblyVersion>
-    <FileVersion>1.0.592</FileVersion>
-    <Version>1.0.592</Version>
+    <Description>Admin.NET 通用权限开发平台</Description>
+    <AssemblyVersion>1.0.596</AssemblyVersion>
+    <FileVersion>1.0.596</FileVersion>
+    <Version>1.0.596</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -1012,6 +1012,18 @@
     <None Update="UpdateScripts\1.0.589.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.591.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.591.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.593.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.593.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 84 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.sql

@@ -0,0 +1,84 @@
+-- 1.0.591
+-- A. 补齐「有 S5_L1_004 值、但本租户没有字典/布局」的缺口。不写死租户号。
+--    模板取 TenantId=0。ParentId 强制 NULL:模板上的 ParentId 指向 0 号租户自己的行,抄过来是错的外键。
+--    SortNo 用 20,与 AidopKpiMasterSeed 的 S5_L1_004 一致,不抄模板的 540。
+-- C. 清掉 T8_V5_SQLSERVER.db_extra_params 里的 ConfigId。
+--    不改 1.0.178.sql:已成功脚本内容一变,AutoVersionUpdate 会因 SHA256 不一致直接拒绝启动。
+
+INSERT INTO ado_smart_ops_kpi_master
+  (Id, MetricCode, ModuleCode, MetricLevel, ParentId, MetricName, Description, Formula,
+   CalcRule, DataSource, StatFrequency, Department, DopFields, Unit, Direction,
+   IsHomePage, SortNo, Remark, IsEnabled, TenantId, CreatedAt, UpdatedAt,
+   YellowThreshold, RedThreshold, FormulaExpr, FormulaPreview, FormulaRefs)
+SELECT
+  base.max_id + gap.rn,
+  tmpl.MetricCode, tmpl.ModuleCode, tmpl.MetricLevel, NULL,
+  tmpl.MetricName, tmpl.Description, tmpl.Formula,
+  tmpl.CalcRule, tmpl.DataSource, tmpl.StatFrequency, tmpl.Department, tmpl.DopFields,
+  tmpl.Unit, tmpl.Direction,
+  tmpl.IsHomePage, 20, tmpl.Remark, tmpl.IsEnabled,
+  gap.tenant_id, NOW(), NOW(),
+  tmpl.YellowThreshold, tmpl.RedThreshold, tmpl.FormulaExpr, tmpl.FormulaPreview, tmpl.FormulaRefs
+FROM (
+  SELECT v.tenant_id, ROW_NUMBER() OVER (ORDER BY v.tenant_id) AS rn
+  FROM (
+    SELECT DISTINCT tenant_id
+    FROM ado_s9_kpi_value_l1_day
+    WHERE metric_code = 'S5_L1_004' AND is_deleted = 0
+  ) v
+  WHERE NOT EXISTS (
+    SELECT 1 FROM ado_smart_ops_kpi_master k
+    WHERE k.TenantId = v.tenant_id AND k.MetricCode = 'S5_L1_004'
+  )
+) gap
+JOIN ado_smart_ops_kpi_master tmpl
+  ON tmpl.TenantId = 0 AND tmpl.MetricCode = 'S5_L1_004'
+CROSS JOIN (
+  SELECT COALESCE(MAX(Id), 0) AS max_id FROM ado_smart_ops_kpi_master
+) base;
+
+-- 有值的工厂补布局行。MetricCode 跨表比较必须显式 COLLATE。
+INSERT INTO ado_smart_ops_layout_item
+  (TenantId, FactoryId, ModuleCode, RowId, MetricLevel, MetricCode, DisplayName,
+   SortNo, ParentRowId, FormulaText, PanelZone, IsEnabled, UpdateTime)
+SELECT
+  k.TenantId, f.factory_id, 'S5', 'S5-L1-S5_L1_004', 1, 'S5_L1_004', '品类物料库存周转',
+  20, NULL, 'D1 / D2 × 30', NULL, 1, NOW()
+FROM ado_smart_ops_kpi_master k
+JOIN (
+  SELECT DISTINCT tenant_id, factory_id
+  FROM ado_s9_kpi_value_l1_day
+  WHERE metric_code = 'S5_L1_004' AND is_deleted = 0
+) f ON f.tenant_id = k.TenantId
+WHERE k.MetricCode = 'S5_L1_004'
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_smart_ops_layout_item l
+    WHERE l.TenantId = k.TenantId
+      AND l.FactoryId = f.factory_id
+      AND l.MetricLevel = 1
+      AND l.MetricCode COLLATE utf8mb4_general_ci = 'S5_L1_004'
+  );
+
+-- 字典已有、同模块已有其它 L1 布局、唯独缺这一行的租户(不要求当下有值)。
+INSERT INTO ado_smart_ops_layout_item
+  (TenantId, FactoryId, ModuleCode, RowId, MetricLevel, MetricCode, DisplayName,
+   SortNo, ParentRowId, FormulaText, PanelZone, IsEnabled, UpdateTime)
+SELECT DISTINCT
+  l.TenantId, l.FactoryId, 'S5', 'S5-L1-S5_L1_004', 1, 'S5_L1_004', '品类物料库存周转',
+  20, NULL, 'D1 / D2 × 30', NULL, 1, NOW()
+FROM ado_smart_ops_layout_item l
+JOIN ado_smart_ops_kpi_master k
+  ON k.TenantId = l.TenantId AND k.MetricCode = 'S5_L1_004'
+WHERE l.ModuleCode = 'S5' AND l.MetricLevel = 1
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_smart_ops_layout_item x
+    WHERE x.TenantId = l.TenantId
+      AND x.FactoryId = l.FactoryId
+      AND x.MetricLevel = 1
+      AND x.MetricCode COLLATE utf8mb4_general_ci = 'S5_L1_004'
+  );
+
+UPDATE mdp_source
+SET db_extra_params = NULL, update_time = NOW()
+WHERE source_code = 'T8_V5_SQLSERVER'
+  AND db_extra_params LIKE '%ConfigId%';

+ 26 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.verify.sql

@@ -0,0 +1,26 @@
+-- 有 S5_L1_004 值的每个租户/工厂,字典行和启用的布局行都要在。
+-- 表还不存在时放行(首次建库)。
+SELECT
+  (SELECT COUNT(*) FROM information_schema.TABLES
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s9_kpi_value_l1_day') = 0
+  OR (
+    SELECT COUNT(*) FROM (
+      SELECT DISTINCT v.tenant_id, v.factory_id
+      FROM ado_s9_kpi_value_l1_day v
+      WHERE v.metric_code = 'S5_L1_004' AND v.is_deleted = 0
+        AND (
+          NOT EXISTS (
+            SELECT 1 FROM ado_smart_ops_kpi_master k
+            WHERE k.TenantId = v.tenant_id AND k.MetricCode = 'S5_L1_004'
+          )
+          OR NOT EXISTS (
+            SELECT 1 FROM ado_smart_ops_layout_item l
+            WHERE l.TenantId = v.tenant_id
+              AND l.FactoryId = v.factory_id
+              AND l.MetricLevel = 1
+              AND l.IsEnabled = 1
+              AND l.MetricCode COLLATE utf8mb4_general_ci = 'S5_L1_004'
+          )
+        )
+    ) missing
+  ) = 0;

+ 31 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.sql

@@ -0,0 +1,31 @@
+-- 首页指标新鲜度 SLA。月度口径按 code 写 45,实时写 1,其余首页指标写 7。
+SET @has_freshness_sla := (
+  SELECT COUNT(*) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA=DATABASE()
+    AND TABLE_NAME='ado_smart_ops_kpi_master'
+    AND COLUMN_NAME='FreshnessSlaDays'
+);
+SET @ddl := IF(
+  @has_freshness_sla=0,
+  'ALTER TABLE ado_smart_ops_kpi_master ADD COLUMN FreshnessSlaDays int NULL COMMENT ''首页数据新鲜度SLA(自然日)'' AFTER StatFrequency',
+  'SELECT 1'
+);
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=45, UpdatedAt=NOW()
+WHERE MetricCode IN
+  ('S5_L1_003','S5_L1_004','S6_L1_003','S7_L1_003','S9_L1_001');
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=1, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND StatFrequency='实时'
+  AND FreshnessSlaDays IS NULL;
+
+UPDATE ado_smart_ops_kpi_master
+SET FreshnessSlaDays=7, UpdatedAt=NOW()
+WHERE IsHomePage=1 AND IsEnabled=1
+  AND FreshnessSlaDays IS NULL;

+ 14 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.593.verify.sql

@@ -0,0 +1,14 @@
+SELECT
+  (SELECT COUNT(*) FROM information_schema.TABLES
+   WHERE TABLE_SCHEMA=DATABASE()
+     AND TABLE_NAME='ado_smart_ops_kpi_master')=0
+  OR (
+    (SELECT COUNT(*) FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA=DATABASE()
+       AND TABLE_NAME='ado_smart_ops_kpi_master'
+       AND COLUMN_NAME='FreshnessSlaDays')=1
+    AND
+    (SELECT COUNT(*) FROM ado_smart_ops_kpi_master
+     WHERE IsHomePage=1 AND IsEnabled=1
+       AND (FreshnessSlaDays IS NULL OR FreshnessSlaDays<=0))=0
+  );

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/EmployeeEmploymentStatusMappingTests.cs

@@ -0,0 +1,39 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class EmployeeEmploymentStatusMappingTests
+{
+    [Fact]
+    public void NativeAndInbound_MapAcToActive_AndDcToLeft()
+    {
+        var native = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "NativeNeutralProjectionService.cs"));
+        var inbound = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "Inbound", "MdmMirrorUpsertService.cs"));
+        Assert.Contains("'ACTIVE','ONJOB','ON_JOB','AC'", native);
+        Assert.Contains("'LEFT','LEAVE','RESIGNED','TERMINATED','QUIT','DC'", native);
+        Assert.Contains("ELSE 'UNKNOWN'", native);
+        Assert.Contains("\"AC\"", inbound);
+        Assert.Contains("\"DC\"", inbound);
+        Assert.Contains("return \"UNKNOWN\"", inbound);
+    }
+
+    [Fact]
+    public void T8Projection_AlreadyMapsChineseStatus_AndIsNotEditedForAc()
+    {
+        var t8 = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "T8BaseInboundMdpSyncService.NeutralProjection.cs"));
+        Assert.Contains("zzzt='在职'", t8);
+        Assert.Contains("zzzt LIKE '%离职%'", t8);
+        Assert.DoesNotContain("'AC'", t8);
+    }
+
+    private static string RepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+    }
+}

+ 105 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/GridAndInboundGuardTests.cs

@@ -0,0 +1,105 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// 九宫格取数与入站诊断日志的守卫。锚在实现正文,避免匹配到接口声明。
+/// </summary>
+public class GridAndInboundGuardTests
+{
+    private static string Read(string relative)
+    {
+        var path = Path.Combine(FindRepoRoot(), relative);
+        return File.ReadAllText(path);
+    }
+
+    [Fact]
+    public void Seed_Defines_S5_L1_004()
+    {
+        var source = Read(@"server\Plugins\Admin.NET.Plugin.AiDOP\Infrastructure\AidopKpiMasterSeed.cs");
+        Assert.Contains("\"S5_L1_004\"", source);
+        Assert.Contains("品类物料库存周转", source);
+    }
+
+    [Fact]
+    public void HomeGrid_ItemBizDate_DoesNotFallBackToModuleDate()
+    {
+        var source = Read(@"server\Plugins\Admin.NET.Plugin.AiDOP\Controllers\AidopKanbanController.Generic.cs");
+        Assert.Contains("biz_date AS BizDate", source);
+        Assert.Contains("禁止拿未筛选的最后真实值补上", source);
+        var items = source.IndexOf("items.Add(new", StringComparison.Ordinal);
+        Assert.True(items > 0);
+        var slice = source[items..source.IndexOf("return Ok", items, StringComparison.Ordinal)];
+        Assert.Contains("bizDate = card.BizDate", slice);
+        Assert.DoesNotContain("bizDate = bizDate", slice);
+    }
+
+    [Fact]
+    public void GateLog_SamplesBeforeConcat_AndDoesNotKillThePipeline()
+    {
+        var inventory = SliceMethod(Read(@"server\Plugins\Admin.NET.Plugin.AiDOP\MaterialWarehouse\InventoryMdpSyncService.cs"),
+            "private async Task LogWorkOrderNotFoundAsync");
+        Assert.Contains("LIMIT 20", inventory);
+        Assert.Contains("try", inventory);
+        Assert.DoesNotContain("GROUP_CONCAT(DISTINCT t.work_ord)", inventory);
+        Assert.Contains("LogWarning", inventory);
+
+        var shipFile = Read(@"server\Plugins\Admin.NET.Plugin.AiDOP\DataPlatform\Wms\ShipTransNeutralProjection.cs");
+        var mark = shipFile.IndexOf("写失败不得中断发运投影", StringComparison.Ordinal);
+        Assert.True(mark > 0);
+        var around = shipFile.Substring(Math.Max(0, mark - 120), 500);
+        Assert.Contains("try", around);
+        Assert.Contains("LIMIT 12", shipFile);
+        Assert.DoesNotContain("GROUP_CONCAT(DISTINCT CONCAT", shipFile);
+        Assert.Contains("LogWarning", shipFile);
+    }
+
+    [Fact]
+    public void ExtraParams_DropsConfigId_AndKeepsForeignKeys()
+    {
+        var dropped = new List<string>();
+        var sqlServer = MdpSourceScopeFactory.SanitizeExtraParams(
+            "ConfigId=t8_v5;TrustServerCertificate=true;Application Name=aidop",
+            DbType.SqlServer, out dropped);
+        Assert.Contains("ConfigId", dropped, StringComparer.OrdinalIgnoreCase);
+        Assert.Contains("TrustServerCertificate", dropped, StringComparer.OrdinalIgnoreCase);
+        Assert.DoesNotContain("ConfigId", sqlServer ?? "", StringComparison.OrdinalIgnoreCase);
+        Assert.Contains("Application Name", sqlServer ?? "", StringComparison.OrdinalIgnoreCase);
+
+        var mysql = MdpSourceScopeFactory.SanitizeExtraParams(
+            "SslMode=Required;Connection Timeout=15",
+            DbType.MySql, out dropped);
+        Assert.Empty(dropped);
+        Assert.Contains("SslMode", mysql ?? "", StringComparison.OrdinalIgnoreCase);
+
+        var pg = MdpSourceScopeFactory.SanitizeExtraParams(
+            "SslMode=Require;Timeout=15",
+            DbType.PostgreSQL, out dropped);
+        Assert.Empty(dropped);
+        Assert.Contains("SslMode", pg ?? "", StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static string SliceMethod(string source, string anchor)
+    {
+        var start = source.IndexOf(anchor, StringComparison.Ordinal);
+        Assert.True(start >= 0, anchor);
+        var end = source.IndexOf("\n    private ", start + anchor.Length, StringComparison.Ordinal);
+        if (end < 0) end = source.IndexOf("\n    public ", start + anchor.Length, StringComparison.Ordinal);
+        if (end < 0) end = source.Length;
+        return source[start..end];
+    }
+
+    private static string FindRepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            if (File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+                return dir.FullName;
+            dir = dir.Parent;
+        }
+        throw new InvalidOperationException("找不到仓库根目录");
+    }
+}

+ 211 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/HomeGridLastKnownValueContractTests.cs

@@ -0,0 +1,211 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class HomeGridLastKnownValueContractTests
+{
+    private static readonly DateTime Today = new(2026, 9, 29);
+
+    [Fact]
+    public void LatestNoData_ShowsPreviousOk_AsLastKnown()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 3m, 10m, "OK", new DateTime(2026, 9, 23)),
+            Row(2, null, null, "NO_DATA", new DateTime(2026, 9, 28)),
+            7, Today);
+        Assert.Equal(3m, card.CurrentValue);
+        Assert.Equal(10m, card.TargetValue);
+        Assert.Equal("LAST_KNOWN_VALUE", card.DisplayState);
+        Assert.Equal(new DateTime(2026, 9, 23), card.BizDate);
+        Assert.Equal("NO_DATA", card.LatestResultStatus);
+        Assert.Equal(new DateTime(2026, 9, 28), card.LatestBizDate);
+    }
+
+    [Fact]
+    public void LatestOk_IsCurrentValue()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(5, 1m, 2m, "OK", Today),
+            Row(5, 1m, 2m, "OK", Today),
+            7, Today);
+        Assert.Equal("CURRENT_VALUE", card.DisplayState);
+        Assert.Equal(0, card.ValueAgeDays);
+        Assert.Equal("FRESH", card.FreshnessState);
+    }
+
+    [Fact]
+    public void NeverHadValue_IsNoData()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            null, Row(3, null, null, "NO_DATA", Today), 7, Today);
+        Assert.Null(card.CurrentValue);
+        Assert.Equal("NO_DATA", card.DisplayState);
+        Assert.Equal("NO_VALUE", card.FreshnessState);
+    }
+
+    [Fact]
+    public void SourceUnsupported_WithoutHistory_StaysUnsupported()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            null, Row(4, null, null, "SOURCE_UNSUPPORTED", Today), 7, Today);
+        Assert.Equal("SOURCE_UNSUPPORTED", card.DisplayState);
+    }
+
+    [Fact]
+    public void ValueAndTarget_ComeFromTheSameUsableRow()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(8, 100m, 80m, "OK", new DateTime(2026, 9, 23)),
+            Row(9, null, 1m, "NO_DATA", Today),
+            7, Today);
+        Assert.Equal(100m, card.CurrentValue);
+        Assert.Equal(80m, card.TargetValue);
+        Assert.NotEqual(1m, card.TargetValue);
+    }
+
+    [Fact]
+    public void BlankHistoricalStatus_IsNormalizedToOk()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(6, 4m, null, "", new DateTime(2026, 9, 1)),
+            Row(7, null, null, "NO_DATA", Today),
+            7, Today);
+        Assert.Equal("OK", card.ResultStatus);
+        Assert.Equal("NO_DATA", card.LatestResultStatus);
+    }
+
+    [Fact]
+    public void DailySla_EightDaysIsStale_SevenDaysIsFresh()
+    {
+        var fresh = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-7)),
+            Row(1, 1m, null, "OK", Today.AddDays(-7)),
+            7, Today);
+        var stale = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-8)),
+            Row(1, 1m, null, "OK", Today.AddDays(-8)),
+            7, Today);
+        Assert.Equal("FRESH", fresh.FreshnessState);
+        Assert.Equal("STALE", stale.FreshnessState);
+    }
+
+    [Fact]
+    public void MonthlySla_ThirtyDaysFresh_FortySixDaysStale()
+    {
+        var fresh = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-30)),
+            Row(1, 1m, null, "OK", Today.AddDays(-30)),
+            45, Today);
+        var stale = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(-46)),
+            Row(1, 1m, null, "OK", Today.AddDays(-46)),
+            45, Today);
+        Assert.Equal("FRESH", fresh.FreshnessState);
+        Assert.Equal("STALE", stale.FreshnessState);
+    }
+
+    [Fact]
+    public void MissingSla_IsPolicyMissing_NotFresh()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today),
+            Row(1, 1m, null, "OK", Today),
+            null, Today);
+        Assert.Equal("POLICY_MISSING", card.FreshnessState);
+    }
+
+    [Fact]
+    public void FutureBizDate_ClampsAgeToZero()
+    {
+        var card = HomeGridDisplayPolicy.Decide(
+            Row(1, 1m, null, "OK", Today.AddDays(2)),
+            Row(1, 1m, null, "OK", Today.AddDays(2)),
+            7, Today);
+        Assert.True(card.FutureBizDate);
+        Assert.Equal(0, card.ValueAgeDays);
+        Assert.Equal("FRESH", card.FreshnessState);
+    }
+
+    [Fact]
+    public void Sql_UsesSameScope_AndDoesNotFallBackOutsideTheRange()
+    {
+        var source = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Controllers", "AidopKanbanController.Generic.cs"));
+        Assert.Contains("ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC", source);
+        Assert.Equal(2, Count(source, "AND (@ds IS NULL OR v.biz_date>=@ds)"));
+        Assert.Equal(2, Count(source, "AND (@de IS NULL OR v.biz_date<=@de)"));
+        Assert.Contains("v.metric_value IS NOT NULL", source);
+        Assert.Contains("v.result_status='OK' OR v.result_status IS NULL OR v.result_status=''", source);
+        Assert.Contains("v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m", source);
+        Assert.Contains("禁止拿未筛选的最后真实值补上", source);
+        Assert.DoesNotContain("ToDictionary", Slice(source, "latestAttemptSql", "public async Task<IActionResult> GetDetailKpisGeneric"));
+    }
+
+    [Fact]
+    public void FutureBizDateWarning_NamesOnlyTenantFactoryMetricAndDate()
+    {
+        var source = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Controllers", "AidopKanbanController.Generic.cs"));
+        var i = source.IndexOf("home grid future biz date", StringComparison.Ordinal);
+        Assert.True(i > 0);
+        var window = source[i..(i + 220)];
+        Assert.Contains("tenant={TenantId}", window);
+        Assert.Contains("factory={FactoryId}", window);
+        Assert.Contains("metric={MetricCode}", window);
+        Assert.Contains("bizDate={BizDate}", window);
+        Assert.DoesNotContain("resultReason", window, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Theory]
+    [InlineData("S5_L1_003", "天", 45)]
+    [InlineData("S5_L1_004", "天", 45)]
+    [InlineData("S6_L1_003", "天", 45)]
+    [InlineData("S7_L1_003", "天", 45)]
+    [InlineData("S9_L1_001", "天", 45)]
+    [InlineData("S8_L1_001", "实时", 1)]
+    [InlineData("S1_L1_001", "天", 7)]
+    public void SeedHelper_UsesMonthlyCodeThenRealtimeThenSeven(string code, string frequency, int expected)
+    {
+        Assert.Equal(expected, AidopKpiMasterSeed.ResolveFreshnessSlaDays(code, frequency));
+    }
+
+    private static HomeGridDisplayPolicy.Row Row(long id, decimal? value, decimal? target, string? status, DateTime bizDate) =>
+        new()
+        {
+            Id = id,
+            MetricValue = value,
+            TargetValue = target,
+            ResultStatus = status,
+            BizDate = bizDate,
+        };
+
+    private static string RepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+    }
+
+    private static string Slice(string src, string start, string end)
+    {
+        var i = src.IndexOf(start, StringComparison.Ordinal);
+        var j = src.IndexOf(end, i, StringComparison.Ordinal);
+        Assert.True(i >= 0 && j > i);
+        return src[i..j];
+    }
+
+    private static int Count(string src, string token)
+    {
+        var n = 0;
+        var i = 0;
+        while ((i = src.IndexOf(token, i, StringComparison.Ordinal)) >= 0)
+        {
+            n++;
+            i += token.Length;
+        }
+        return n;
+    }
+}

+ 148 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpSourcePasswordResolverTests.cs

@@ -0,0 +1,148 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.Job;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class MdpSourcePasswordResolverTests
+{
+    private const string SourceCode = "UNIT_TEST_SRC";
+    private const string Secret = "unit-test-secret-value";
+
+    [Fact]
+    public void Env_wins_over_stored_secret()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, Secret);
+            var logger = new ListLogger();
+            var value = new MdpSourcePasswordResolver(logger).Resolve(SourceCode, "stored-cipher");
+            Assert.Equal(Secret, value);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_uses_successful_decrypt_without_logging_it()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var logger = new ListLogger();
+            var value = MdpSourcePasswordResolver.ResolveCore(
+                SourceCode, "cipher-blob", _ => "opened-secret", logger);
+            Assert.Equal("opened-secret", value);
+            Assert.DoesNotContain("cipher-blob", logger.Text);
+            Assert.DoesNotContain("opened-secret", logger.Text);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_uses_legacy_plaintext_without_logging_it()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var logger = new ListLogger();
+            var value = new MdpSourcePasswordResolver(logger).Resolve(SourceCode, Secret);
+            Assert.Equal(Secret, value);
+            Assert.Contains("legacy plaintext", logger.Text);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_and_stored_secret_throws_SECRET_MISSING()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var ex = Assert.Throws<MdpSourceSecretException>(() =>
+                new MdpSourcePasswordResolver().Resolve(SourceCode, null));
+            Assert.Equal("SECRET_MISSING", ex.Code);
+            Assert.Contains(env, ex.Message);
+            Assert.DoesNotContain(Secret, ex.Message);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Health_classifier_does_not_keep_connection_text()
+    {
+        Assert.Equal("SECRET_MISSING", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new MdpSourceSecretException("SECRET_MISSING", "set the env")));
+        Assert.Equal("AUTH_FAILED", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new InvalidOperationException("Login failed for user 'dopsa'")));
+        Assert.Equal("NETWORK_FAILED", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new TimeoutException("Unable to connect to the server")));
+        Assert.Equal("连接失败,详情见运行日志", MdpSourceHealthCheckJob.SafeHealthMessage(
+            "Password=unit-test-secret-value;Server=example"));
+        Assert.DoesNotContain(Secret, MdpSourceHealthCheckJob.SafeHealthMessage(
+            "Password=unit-test-secret-value;Server=example"));
+    }
+
+    [Fact]
+    public void Database_json_no_longer_contains_t8_v5()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        string? path = null;
+        while (dir != null)
+        {
+            var candidate = Path.Combine(dir.FullName, "server", "Admin.NET.Application", "Configuration", "Database.json");
+            if (File.Exists(candidate))
+            {
+                path = candidate;
+                break;
+            }
+            dir = dir.Parent;
+        }
+        Assert.False(string.IsNullOrEmpty(path), "Database.json not found from " + AppContext.BaseDirectory);
+        var text = File.ReadAllText(path);
+        Assert.DoesNotContain("t8_v5", text, StringComparison.OrdinalIgnoreCase);
+    }
+
+    private sealed class ListLogger : ILogger<MdpSourcePasswordResolver>
+    {
+        public string Text { get; private set; } = "";
+
+        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
+
+        public bool IsEnabled(LogLevel logLevel) => true;
+
+        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+        {
+            Text += formatter(state, exception);
+        }
+
+        private sealed class NullScope : IDisposable
+        {
+            public static readonly NullScope Instance = new();
+            public void Dispose() { }
+        }
+    }
+}

+ 92 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryInboundLockGuardTests.cs

@@ -0,0 +1,92 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Microsoft.Extensions.Logging.Abstractions;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.MaterialWarehouse;
+
+/// <summary>
+/// 库存物化有界等待锁的进程内行为。不连真实库。
+/// 跨连接互斥留在 S5 集成测试,仅当 AIDOP_IT=1 时执行。
+/// </summary>
+public class InventoryInboundLockGuardAcquireTests
+{
+    [Fact]
+    public void ProductionDefaults_AreTenMinutesAndTwoSeconds()
+    {
+        Assert.Equal(TimeSpan.FromMinutes(10), InventoryInboundLockGuard.DefaultWaitTimeout);
+        Assert.Equal(TimeSpan.FromSeconds(2), InventoryInboundLockGuard.DefaultPollInterval);
+    }
+
+    [Fact]
+    public async Task AcquireAsync_PreCancelledToken_StopsBeforeWaiting()
+    {
+        using var db = ClosedClient();
+        using var cts = new CancellationTokenSource();
+        cts.Cancel();
+
+        var started = DateTime.UtcNow;
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
+            InventoryInboundLockGuard.AcquireAsync(
+                db, NullLogger.Instance, "aidop:t:inv-in:cancel",
+                TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(50), cts.Token));
+        Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(2));
+    }
+
+    [Fact]
+    public async Task AcquireAsync_UnreachableDatabase_TimesOutWithFixedMessage()
+    {
+        using var db = ClosedClient();
+        var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
+            InventoryInboundLockGuard.AcquireAsync(
+                db, NullLogger.Instance, "aidop:t:inv-in:timeout",
+                TimeSpan.FromMilliseconds(200), TimeSpan.FromMilliseconds(50)));
+        Assert.Equal("inventory materialization lock busy", ex.Message);
+    }
+
+    [Fact]
+    public void TenantMaterializeEntry_WaitsForLock_BackgroundPathsStayNonBlocking()
+    {
+        var src = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "InventoryMdpSyncService.cs"));
+        var method = Slice(src, "public async Task<int> TransformTransStdFromStgAsync(", "private async Task<InventorySyncResult> RunAsync");
+        Assert.Contains("InventoryInboundLockGuard.AcquireAsync(", method, StringComparison.Ordinal);
+        Assert.DoesNotContain("TryAcquireAsync", method, StringComparison.Ordinal);
+        Assert.True(Count(src, "InventoryInboundLockGuard.TryAcquireAsync(") >= 3);
+    }
+
+    private static SqlSugarClient ClosedClient() => new(new ConnectionConfig
+    {
+        DbType = DbType.MySql,
+        ConnectionString = "Server=127.0.0.1;Port=1;User ID=x;Password=x;Database=x;Connection Timeout=1;Pooling=false;",
+        IsAutoCloseConnection = true,
+    });
+
+    private static string RepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+    }
+
+    private static string Slice(string src, string start, string end)
+    {
+        var i = src.IndexOf(start, StringComparison.Ordinal);
+        var j = src.IndexOf(end, i, StringComparison.Ordinal);
+        Assert.True(i >= 0 && j > i);
+        return src[i..j];
+    }
+
+    private static int Count(string src, string token)
+    {
+        var n = 0;
+        var i = 0;
+        while ((i = src.IndexOf(token, i, StringComparison.Ordinal)) >= 0)
+        {
+            n++;
+            i += token.Length;
+        }
+        return n;
+    }
+}

+ 126 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/MaterialWarehouse/InventoryMaterializationRetryTests.cs

@@ -0,0 +1,126 @@
+using System.Reflection;
+using MySqlConnector;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.MaterialWarehouse;
+
+public class InventoryMaterializationRetryTests
+{
+    [Fact]
+    public async Task Deadlock1213_RetriesUntilThirdAttempt()
+    {
+        var calls = 0;
+        var ex = await Assert.ThrowsAsync<MySqlException>(() =>
+            InventoryMaterializationRetry.ExecuteAsync<int>(async () =>
+            {
+                calls++;
+                throw Mysql(1213, "deadlock");
+            }));
+        Assert.Equal(3, calls);
+        Assert.Equal(1213, ex.Number);
+    }
+
+    [Fact]
+    public async Task LockWaitTimeout1205_IsRetried()
+    {
+        var calls = 0;
+        await InventoryMaterializationRetry.ExecuteAsync(() =>
+        {
+            calls++;
+            if (calls < 3)
+                throw Mysql(1205, "lock wait");
+            return Task.FromResult(calls);
+        });
+        Assert.Equal(3, calls);
+    }
+
+    [Fact]
+    public async Task RetryableInnerException_IsRetried()
+    {
+        var calls = 0;
+        var result = await InventoryMaterializationRetry.ExecuteAsync(() =>
+        {
+            calls++;
+            if (calls == 1)
+                throw new InvalidOperationException("wrap",
+                    Mysql(1213, "deadlock"));
+            return Task.FromResult(7);
+        });
+        Assert.Equal(7, result);
+        Assert.Equal(2, calls);
+    }
+
+    [Fact]
+    public async Task NonConcurrencySqlError_ThrowsImmediately()
+    {
+        var calls = 0;
+        var ex = await Assert.ThrowsAsync<MySqlException>(() =>
+            InventoryMaterializationRetry.ExecuteAsync<int>(async () =>
+            {
+                calls++;
+                throw Mysql(1054, "unknown column");
+            }));
+        Assert.Equal(1, calls);
+        Assert.Equal(1054, ex.Number);
+    }
+
+    [Fact]
+    public async Task CancelledToken_StopsBeforeAnotherAttempt()
+    {
+        using var cts = new CancellationTokenSource();
+        var calls = 0;
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
+            InventoryMaterializationRetry.ExecuteAsync<int>(async () =>
+            {
+                calls++;
+                cts.Cancel();
+                throw Mysql(1213, "deadlock");
+            }, cts.Token));
+        Assert.Equal(1, calls);
+    }
+
+    [Fact]
+    public void MaterializeCommand_IsTheOnlyRetriedStatement()
+    {
+        var src = File.ReadAllText(Path.Combine(RepoRoot(),
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "InventoryMdpSyncService.cs"));
+        Assert.Contains("InventoryMaterializationRetry.ExecuteAsync(", src, StringComparison.Ordinal);
+        Assert.Equal(1, Count(src, "InventoryMaterializationRetry.ExecuteAsync("));
+        var i = src.IndexOf("InventoryMaterializationRetry.ExecuteAsync(", StringComparison.Ordinal);
+        var window = src[i..(i + 180)];
+        Assert.Contains("ExecuteCommandAsync(sql, ps)", window, StringComparison.Ordinal);
+    }
+
+    private static MySqlException Mysql(int number, string message)
+    {
+        var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+        foreach (var ctor in typeof(MySqlException).GetConstructors(flags))
+        {
+            var ps = ctor.GetParameters();
+            if (ps.Length == 2 && ps[1].ParameterType == typeof(string) && ps[0].ParameterType.IsEnum)
+                return (MySqlException)ctor.Invoke([Enum.ToObject(ps[0].ParameterType, number), message]);
+        }
+        throw new InvalidOperationException("MySqlException has no (errorCode, message) constructor");
+    }
+
+    private static string RepoRoot()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+    }
+
+    private static int Count(string src, string token)
+    {
+        var n = 0;
+        var i = 0;
+        while ((i = src.IndexOf(token, i, StringComparison.Ordinal)) >= 0)
+        {
+            n++;
+            i += token.Length;
+        }
+        return n;
+    }
+}

+ 23 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryInboundLockGuardTests.cs

@@ -220,4 +220,27 @@ public class InventoryInboundLockGuardTests
         await using var second = await InventoryInboundLockGuard.TryAcquireAsync(legacy, NullLogger.Instance, key);
         Assert.True(second.Acquired, $"acquire failed: {second.BusyReason}");
     }
+
+    /// <summary>重算入口的有界等待:对方持锁时本调用不立即失败,释放后拿到同一把锁。</summary>
+    [Fact]
+    public async Task Case6_AcquireAsync_WaitsForRelease_ThenSucceeds()
+    {
+        if (!Enabled) return;
+        var cs = ReadAidopdevConnectionString();
+        Assert.False(string.IsNullOrEmpty(cs));
+        var db = BuildSharedClient(cs);
+        var key = NewKey("case6");
+
+        await using var holder = await InventoryInboundLockGuard.TryAcquireAsync(db, NullLogger.Instance, key);
+        Assert.True(holder.Acquired, holder.BusyReason);
+
+        var waiting = InventoryInboundLockGuard.AcquireAsync(
+            db, NullLogger.Instance, key, TimeSpan.FromSeconds(8), TimeSpan.FromMilliseconds(200));
+        await Task.Delay(500);
+        Assert.False(waiting.IsCompleted);
+
+        await holder.DisposeAsync();
+        await using var acquired = await waiting.WaitAsync(TimeSpan.FromSeconds(5));
+        Assert.True(acquired.Acquired);
+    }
 }

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryReconScopeTests.cs

@@ -278,7 +278,7 @@ public class InventoryReconScopeTests
         var scope = await ScopeAsync(db, tenantId, Domain);
         Assert.False(scope.IsEmpty);
 
-        var remote = await new MdpSourceScopeFactory(db).GetScopeAsync(SourceCode);
+        var remote = await new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()).GetScopeAsync(SourceCode);
 
         var (clause, locPars) = scope.BuildInClause("Location", "loc");
         var pars = new List<SugarParameter> { new("@Domain", Domain) };

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryTenantIsolationIntegrationTests.cs

@@ -46,7 +46,7 @@ public class InventoryTenantIsolationIntegrationTests
     private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
 
     private static Live165InventorySource Live(ISqlSugarClient db) =>
-        new(new MdpSourceScopeFactory(db), db, Options.Create(new AidopInventoryOptions()));
+        new(new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(new AidopInventoryOptions()));
 
     private static InventoryBalanceReader Reader(ISqlSugarClient db) =>
         new(Live(db), new StdInventorySource(db, Options.Create(new AidopInventoryOptions())),

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StdInventoryTenantIsolationTests.cs

@@ -257,7 +257,7 @@ public class StdInventoryTenantIsolationTests
         // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → GetScopeAsync 抛错 → 触发 fallback。
         // 生产代码零改动;STD 侧仍用正确源码。
         var brokenLive = new Live165InventorySource(
-            new MdpSourceScopeFactory(db), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
+            new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
         var reader = new InventoryBalanceReader(
             brokenLive, Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StockQueryUiAndPaginationTests.cs

@@ -45,7 +45,7 @@ public class StockQueryUiAndPaginationTests
     private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
     private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
     private static Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) =>
-        new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode)));
+        new(new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(Opt(sourceCode)));
 
     private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize,
         string location = null, string materialCode = null) => new()

+ 43 - 21
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs

@@ -32,6 +32,7 @@ public class S8DashboardSemanticsTests
 
     private static string MonitoringCode() => ReadCode("Service/S8/S8MonitoringService.cs");
     private static string KanbanCode() => ReadCode("Controllers/AidopKanbanController.cs");
+    private static string HomeAlertCode() => ReadCode("Service/S8/S8HomeAlertQueryService.cs");
 
     /// <summary>
     /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。
@@ -302,14 +303,15 @@ public class S8DashboardSemanticsTests
         Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode());
     }
 
-    /// <summary>⑲ 汇总端点必须 COUNT,且不得带 LIMIT。</summary>
+    /// <summary>⑲ 汇总计数在异常权威查询里,且不得带 LIMIT。</summary>
     [Fact]
     public void T19_AlertSummary_CountsWithoutLimit()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
         Assert.Contains("COUNT(*)", body);
         Assert.DoesNotContain("LIMIT", body);
+        Assert.DoesNotContain("ado_s8_alert_record", body);
     }
 
     /// <summary>⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。</summary>
@@ -324,46 +326,66 @@ public class S8DashboardSemanticsTests
         Assert.Contains("isToday", body);
     }
 
-    /// <summary>㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。</summary>
+    /// <summary>㉑ 汇总按当前租户统计新建异常,窗口是 created_at 半开区间,不按工厂过滤。</summary>
     [Fact]
     public void T21_AlertSummary_IsTenantScoped()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
-        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", body);
+        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", controller);
         Assert.Contains("tenant_id=@tenantId", body);
-        Assert.Contains("factory_id=@factoryId", body);
-        Assert.Contains("alert_time >= @ws", body);
-        Assert.Contains("alert_time < @we", body);
+        Assert.DoesNotContain("factory_id=@factoryId", body);
+        Assert.Contains("created_at >= @ws", body);
+        Assert.Contains("created_at < @we", body);
+        Assert.DoesNotContain("alert_time", body);
+        Assert.DoesNotContain("last_detected_at", body);
     }
 
-    /// <summary>㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。</summary>
+    /// <summary>㉒ 三个首页查询复用同一套异常字段过滤。</summary>
     [Fact]
     public void T22_AlertSummary_ReusesSharedDimensionFilter()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        Assert.Contains("SmartOpsDashboardFilter.FromQuery", controller);
 
-        Assert.Contains("BuildS8AlertTextFilterSql()", body);
-        Assert.Contains("SmartOpsDashboardFilter.FromQuery", body);
+        foreach (var signature in new[]
+        {
+            "public async Task<List<S8HomeAlertItem>> GetRecentAsync",
+            "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync",
+            "public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync"
+        })
+        {
+            Assert.Contains("TextFilterSql", MethodBody(HomeAlertCode(), signature));
+        }
     }
 
-    /// <summary>㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。</summary>
+    /// <summary>㉓ 首页列表改读未关闭异常,仍只返回最近 6 条,排序用最近发现时间。</summary>
     [Fact]
-    public void T23_LegacyAlertsEndpoint_StillReturnsRecentList()
+    public void T23_AlertsEndpoint_ReadsOpenExceptions()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
-
-        Assert.Contains("LIMIT 6", body);
-        Assert.Contains("ORDER BY alert_time DESC", body);
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
+        var body = MethodBody(HomeAlertCode(), "public async Task<List<S8HomeAlertItem>> GetRecentAsync");
+
+        Assert.DoesNotContain("ado_s8_alert_record", controller);
+        Assert.Contains("ado_s8_exception", body);
+        Assert.Contains("LIMIT {RecentLimit}", body);
+        Assert.Contains("public const int RecentLimit = 6;", HomeAlertCode());
+        Assert.Contains("ORDER BY COALESCE(last_detected_at, created_at) DESC, id DESC", body);
+        Assert.Contains("ActiveStatusSql", body);
+        Assert.Contains("status NOT IN ('CLOSED','REJECTED','RECOVERED')", HomeAlertCode());
+        Assert.DoesNotContain("ado_s8_alert_record", body);
     }
 
     /// <summary>㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。</summary>
     [Fact]
     public void T24_AlertSummary_ReturnsCriticalCount()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
-        Assert.Contains("critical", body);
-        Assert.Contains("level_code", body);
+        Assert.Contains("critical", controller);
+        Assert.Contains("SERIOUS", body);
+        Assert.DoesNotContain("level_code", body);
     }
 }

+ 194 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8HomeAlertQueryServiceTests.cs

@@ -0,0 +1,194 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+public class S8HomeAlertQueryServiceTests
+{
+    private static readonly DateTime Now = new(2026, 9, 30, 12, 0, 0);
+
+    [Fact]
+    public void SeriousOutranksFollow()
+    {
+        var decision = S8ModuleLampPolicy.Decide(1, 4, new[] { Healthy() }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void SeriousOpenException_StaysRedWhenRuleIsDisabled()
+    {
+        var decision = S8ModuleLampPolicy.Decide(1, 0, new[] { Rule(enabled: false) }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("PAUSED", decision.MonitorState);
+        Assert.Equal("RULE_DISABLED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void FollowOnly_IsWarningWhileMonitorStaysHealthy()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 2, new[] { Healthy() }, Now);
+
+        Assert.Equal("WARNING", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+        Assert.Equal("OK", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void GreenRequiresHealthyMonitorAndZeroOpenExceptions()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Healthy() }, Now);
+
+        Assert.Equal("NORMAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void UnmonitoredModule_IsUnknown_NotGreen()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, Array.Empty<S8WatchRuleSignal>(), Now);
+
+        Assert.Equal("UNKNOWN", decision.BusinessState);
+        Assert.Equal("UNMONITORED", decision.MonitorState);
+        Assert.Equal("NO_RULE", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void StaleRunnableRule_IsDegraded_AndDoesNotTurnGreen()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Healthy(Now.AddMinutes(-11)) }, Now);
+
+        Assert.Equal("UNKNOWN", decision.BusinessState);
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("RUN_STALE", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void FailedRun_OutranksStaleAndNeverRun()
+    {
+        var rules = new[]
+        {
+            Healthy(Now.AddHours(-2)),
+            Rule(enabled: true, lastStatus: "FAILED", lastRunAt: Now.AddMinutes(-1))
+        };
+
+        var decision = S8ModuleLampPolicy.Decide(0, 0, rules, Now);
+
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("RUN_FAILED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void NeverRun_IsDegraded()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Rule(enabled: true) }, Now);
+
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("NEVER_RUN", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void EnabledRulePausedUntilFuture_IsPausedNotUnmonitored()
+    {
+        var decision = S8ModuleLampPolicy.Decide(
+            3, 0, new[] { Rule(enabled: true, pausedUntil: Now.AddHours(1), lastStatus: "SUCCESS", lastRunAt: Now) }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("PAUSED", decision.MonitorState);
+        Assert.Equal("RULE_PAUSED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void OneHealthyRunnableRule_IgnoresASiblingPausedRule()
+    {
+        var rules = new[]
+        {
+            Healthy(),
+            Rule(enabled: true, pausedUntil: Now.AddHours(1))
+        };
+
+        var decision = S8ModuleLampPolicy.Decide(0, 0, rules, Now);
+
+        Assert.Equal("NORMAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void FreshWindow_IsAtLeastTenMinutes()
+    {
+        Assert.Equal(600, S8ModuleLampPolicy.FreshWindowSeconds(30));
+        Assert.Equal(800, S8ModuleLampPolicy.FreshWindowSeconds(400));
+    }
+
+    [Fact]
+    public void TrendCountsCreatedAt_AndDoesNotUseLastDetectedAt()
+    {
+        var code = ReadService();
+        var trend = MethodBody(code, "public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync");
+        var summary = MethodBody(code, "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
+
+        Assert.Contains("DATE(created_at)", trend);
+        Assert.DoesNotContain("last_detected_at", trend);
+        Assert.DoesNotContain("status NOT IN", summary);
+        Assert.DoesNotContain("factory_id", trend);
+        Assert.Contains("ado_s8_watch_rule", code);
+        var lamps = MethodBody(code, "public async Task<S8ModuleLampPayload> GetModuleLampsAsync");
+        Assert.Contains("status NOT IN ('CLOSED','REJECTED','RECOVERED')", lamps);
+        Assert.Contains("S8ModuleCode.All", lamps);
+        Assert.DoesNotContain("DateTime.Now", lamps);
+        var endpoint = MethodBody(ReadController(), "public async Task<IActionResult> GetS8ModuleLamps");
+        Assert.Contains("[HttpGet(\"s8-module-lamps\")]", ReadController());
+        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", endpoint);
+        Assert.DoesNotContain("[FromQuery]", endpoint);
+        Assert.DoesNotContain("factoryId", endpoint);
+        foreach (var signature in new[]
+        {
+            "public async Task<IActionResult> GetS8Alerts",
+            "public async Task<IActionResult> GetS8AlertSummary",
+            "public async Task<IActionResult> GetS8HomeTrend"
+        })
+        {
+            Assert.DoesNotContain("ado_s8_alert_record", MethodBody(ReadController(), signature));
+        }
+    }
+
+    private static S8WatchRuleSignal Healthy(DateTime? lastRunAt = null) =>
+        Rule(enabled: true, lastStatus: "SUCCESS", lastRunAt: lastRunAt ?? Now.AddMinutes(-1));
+
+    private static S8WatchRuleSignal Rule(
+        bool enabled,
+        string? lastStatus = null,
+        DateTime? lastRunAt = null,
+        DateTime? pausedUntil = null) =>
+        new("S1", enabled, 300, lastStatus, lastRunAt, pausedUntil);
+
+    private static string ReadService() => Strip(Path.Combine(PluginRoot, "Service/S8/S8HomeAlertQueryService.cs"));
+    private static string ReadController() => Strip(Path.Combine(PluginRoot, "Controllers/AidopKanbanController.cs"));
+
+    private static string PluginRoot => Path.GetFullPath(
+        Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string Strip(string path)
+    {
+        var raw = File.ReadAllText(path);
+        var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+        return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
+    }
+
+    private static string MethodBody(string code, string signature)
+    {
+        var start = code.IndexOf(signature, StringComparison.Ordinal);
+        Assert.True(start > 0, signature);
+        var from = start + signature.Length;
+        var end = code.Length;
+        foreach (var kw in new[] { "\n    public ", "\n    private ", "\n    internal ", "\n    protected " })
+        {
+            var i = code.IndexOf(kw, from, StringComparison.Ordinal);
+            if (i > 0 && i < end) end = i;
+        }
+        return code[start..end];
+    }
+}

+ 123 - 23
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -1,6 +1,7 @@
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -92,29 +93,56 @@ public partial class AidopKanbanController
 
         var bizDate = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId, mc, filter.DateStart, filter.DateEnd);
 
-        const string valSql = """
-SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
-       v.result_status AS ResultStatus, v.result_reason AS ResultReason
-FROM ado_s9_kpi_value_l1_day v
-WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
-  AND v.biz_date=(
-      SELECT MAX(v2.biz_date)
-      FROM ado_s9_kpi_value_l1_day v2
-      WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
-        AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0
-        AND (@ds IS NULL OR v2.biz_date >= @ds)
-        AND (@de IS NULL OR v2.biz_date <= @de)
-  )
+        const string latestAttemptSql = """
+WITH ranked AS (
+  SELECT v.*,
+         ROW_NUMBER() OVER (
+           PARTITION BY v.metric_code
+           ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
+         ) AS rn
+  FROM ado_s9_kpi_value_l1_day v
+  WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
+    AND v.is_deleted=0
+    AND (@ds IS NULL OR v.biz_date>=@ds)
+    AND (@de IS NULL OR v.biz_date<=@de)
+)
+SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
+       target_value AS TargetValue, result_status AS ResultStatus,
+       result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
+FROM ranked
+WHERE rn=1
 """;
-        var vals = await _db.Ado.SqlQueryAsync<S4ValRow>(valSql, new
+        const string latestUsableSql = """
+WITH ranked AS (
+  SELECT v.*,
+         ROW_NUMBER() OVER (
+           PARTITION BY v.metric_code
+           ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
+         ) AS rn
+  FROM ado_s9_kpi_value_l1_day v
+  WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
+    AND v.is_deleted=0
+    AND (@ds IS NULL OR v.biz_date>=@ds)
+    AND (@de IS NULL OR v.biz_date<=@de)
+    AND v.metric_value IS NOT NULL
+    AND (v.result_status='OK' OR v.result_status IS NULL OR v.result_status='')
+)
+SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
+       target_value AS TargetValue, result_status AS ResultStatus,
+       result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
+FROM ranked
+WHERE rn=1
+""";
+        var valueArgs = new
         {
             t = tenantId,
             f = factoryId,
             m = mc,
             ds = filter.DateStart,
             de = filter.DateEnd
-        });
-        var valBy = vals.ToDictionary(x => x.MetricCode ?? "", StringComparer.OrdinalIgnoreCase);
+        };
+        var latestBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestAttemptSql, valueArgs));
+        var usableBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestUsableSql, valueArgs));
         DashboardValuesBundle? filteredBundle = null;
         if (filter.HasBusinessDimension)
             filteredBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpi);
@@ -127,15 +155,43 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
         foreach (var row in layout)
         {
             kpiBy.TryGetValue(row.MetricCode, out var k);
-            valBy.TryGetValue(row.MetricCode, out var v);
-            decimal? cur = v?.MetricValue;
-            var tgt = v?.TargetValue;
+            HomeGridDisplayPolicy.Card card;
             if (filteredBundle != null)
             {
+                // 业务维度旁路没有历史业务日。只返回本次筛选结果,禁止拿未筛选的最后真实值补上。
                 filteredBundle.Values.TryGetValue(row.MetricCode, out var fv);
-                cur = fv?.MetricValue;
-                tgt ??= fv?.TargetValue;
+                var filteredStatus = fv?.MetricValue == null
+                    ? (string.IsNullOrWhiteSpace(fv?.ResultStatus) ? "NO_DATA" : fv.ResultStatus)
+                    : (string.IsNullOrWhiteSpace(fv.ResultStatus) ? "OK" : fv.ResultStatus);
+                card = new HomeGridDisplayPolicy.Card
+                {
+                    CurrentValue = fv?.MetricValue,
+                    TargetValue = fv?.TargetValue,
+                    ResultStatus = filteredStatus,
+                    ResultReason = fv?.ResultReason,
+                    DisplayState = fv?.MetricValue == null
+                        ? (string.Equals(fv?.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)
+                            ? "SOURCE_UNSUPPORTED" : "NO_DATA")
+                        : "CURRENT_VALUE",
+                    FreshnessState = "NO_VALUE",
+                    FreshnessSlaDays = k?.FreshnessSlaDays,
+                };
+            }
+            else
+            {
+                usableBy.TryGetValue(row.MetricCode, out var usable);
+                latestBy.TryGetValue(row.MetricCode, out var latest);
+                card = HomeGridDisplayPolicy.Decide(
+                    ToPolicyRow(usable), ToPolicyRow(latest), k?.FreshnessSlaDays, DateTime.Today);
+                if (card.FutureBizDate)
+                {
+                    _logger.LogWarning(
+                        "home grid future biz date tenant={TenantId} factory={FactoryId} metric={MetricCode} bizDate={BizDate}",
+                        tenantId, factoryId, row.MetricCode, card.BizDate?.ToString("yyyy-MM-dd"));
+                }
             }
+            var cur = card.CurrentValue;
+            var tgt = card.TargetValue;
             var dir = k?.Direction ?? "higher_is_better";
             var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
             var gap = AidopS4KpiMerge.GapValue(cur, tgt);
@@ -149,8 +205,16 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
                 unit = k?.Unit ?? "",
                 currentValue = cur,
                 targetValue = tgt,
-                resultStatus = v?.ResultStatus,
-                resultReason = v?.ResultReason,
+                resultStatus = card.ResultStatus,
+                resultReason = card.ResultReason,
+                bizDate = card.BizDate?.ToString("yyyy-MM-dd"),
+                latestResultStatus = card.LatestResultStatus,
+                latestResultReason = card.LatestResultReason,
+                latestBizDate = card.LatestBizDate?.ToString("yyyy-MM-dd"),
+                displayState = card.DisplayState,
+                freshnessState = card.FreshnessState,
+                valueAgeDays = card.ValueAgeDays,
+                freshnessSlaDays = card.FreshnessSlaDays,
                 gapValue = gap,
                 gapLabel = label,
                 gapArrow = arrow,
@@ -163,6 +227,7 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
         {
             moduleCode = mc,
             layoutPattern = hm?.LayoutPattern ?? "card_grid",
+            // 模块内最新的一天,不是每张卡的业务日。卡片日期看 items[].bizDate。
             bizDate = bizDate.ToString("yyyy-MM-dd"),
             items
         });
@@ -529,4 +594,39 @@ WHERE tenant_id=@t AND module_code=@m AND is_deleted=0
 
         return Ok(new { ok = true });
     }
+
+    private sealed class HomeGridValueRow
+    {
+        public long Id { get; set; }
+        public string? MetricCode { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+        public string? ResultStatus { get; set; }
+        public string? ResultReason { get; set; }
+        public DateTime BizDate { get; set; }
+        public DateTime? CalcTime { get; set; }
+    }
+
+    private static Dictionary<string, HomeGridValueRow> IndexHomeGridValues(List<HomeGridValueRow> rows)
+    {
+        var map = new Dictionary<string, HomeGridValueRow>(StringComparer.OrdinalIgnoreCase);
+        foreach (var row in rows ?? [])
+        {
+            var code = row.MetricCode ?? "";
+            if (!map.TryAdd(code, row))
+                throw new InvalidOperationException($"home grid duplicated metric {code}");
+        }
+        return map;
+    }
+
+    private static HomeGridDisplayPolicy.Row? ToPolicyRow(HomeGridValueRow? row) =>
+        row == null ? null : new HomeGridDisplayPolicy.Row
+        {
+            Id = row.Id,
+            MetricValue = row.MetricValue,
+            TargetValue = row.TargetValue,
+            ResultStatus = row.ResultStatus,
+            ResultReason = row.ResultReason,
+            BizDate = row.BizDate,
+        };
 }

+ 2 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S4.cs

@@ -403,5 +403,7 @@ WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0
         public decimal? TargetValue { get; set; }
         public string? ResultStatus { get; set; }
         public string? ResultReason { get; set; }
+        /// <summary>该指标自己的业务日。S4 首页 SQL 不选此列,保持 null,不影响 S4。</summary>
+        public DateTime? BizDate { get; set; }
     }
 }

+ 51 - 114
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -10,6 +10,7 @@ using Admin.NET.Plugin.AiDOP.Production;
 using Admin.NET.Plugin.AiDOP.Supply;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -40,6 +41,8 @@ public partial class AidopKanbanController : ControllerBase
     private readonly SmartOpsTrustedScopeResolver _trustedScope;
     private readonly SmartDiagnosisEvidenceService _evidence;
     private readonly S8KpiSnapshotService _s8KpiSnapshot;
+    private readonly S8HomeAlertQueryService _s8HomeAlerts;
+    private readonly ILogger<AidopKanbanController> _logger;
 
     public AidopKanbanController(
         ISqlSugarClient db,
@@ -62,7 +65,9 @@ public partial class AidopKanbanController : ControllerBase
         ReportWorkMdpSyncService reportWorkMdpSyncService,
         SmartOpsTrustedScopeResolver trustedScope,
         SmartDiagnosisEvidenceService evidence,
-        S8KpiSnapshotService s8KpiSnapshot)
+        S8KpiSnapshotService s8KpiSnapshot,
+        S8HomeAlertQueryService s8HomeAlerts,
+        ILogger<AidopKanbanController> logger)
     {
         _db = db;
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
@@ -85,8 +90,15 @@ public partial class AidopKanbanController : ControllerBase
         _trustedScope = trustedScope;
         _evidence = evidence;
         _s8KpiSnapshot = s8KpiSnapshot;
+        _s8HomeAlerts = s8HomeAlerts;
+        _logger = logger;
     }
 
+    /// <summary>
+    /// 已弃用。前端零调用点,九宫格实际走 <c>home-grid/{moduleCode}</c>(<see cref="GetHomeGridGeneric"/>)。
+    /// 本端点用全局 <c>MAX(biz_date)</c> 卡所有模块,没有该日数据的模块会被整段筛掉,不可复用、不可接回。
+    /// </summary>
+    [Obsolete("前端零调用点。九宫格走 home-grid/{moduleCode}。全局 MAX(biz_date) 会筛掉其它模块,不可复用。")]
     [HttpGet("home-l1")]
     public async Task<IActionResult> GetHomeL1([FromQuery] long factoryId = 1)
     {
@@ -129,62 +141,27 @@ ORDER BY v.module_code
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT DATE_FORMAT(alert_time, '%H:%i') AS Time,
-       IFNULL(SUBSTRING_INDEX(metric_code, '_', 1), 'S8') AS Module,
-       IFNULL(message, '异常告警') AS Message,
-       IFNULL(level_code, 'medium') AS LevelCode
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND (@ds IS NULL OR alert_time >= @ds)
-  AND (@de IS NULL OR alert_time < DATE_ADD(@de, INTERVAL 1 DAY))
-  {dimSql}
-ORDER BY alert_time DESC
-LIMIT 6
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8AlertDto>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ds = filter.DateStart,
-            de = filter.DateEnd,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
+        var rows = await _s8HomeAlerts.GetRecentAsync(tenantId, filter);
         return Ok(rows.Select(x => new
         {
             time = x.Time,
             module = x.Module,
             message = x.Message,
-            level = x.LevelCode,
-            levelLabel = x.LevelCode switch
-            {
-                "critical" => "严重",
-                "high" => "高",
-                "medium" => "中",
-                _ => "一般"
-            }
+            level = x.Level,
+            levelLabel = x.LevelLabel
         }));
     }
 
     /// <summary>
-    /// S8-DASHBOARD-P2-SEMANTICS-1(P2-B):S8 看板预警计数。
-    ///
-    /// <para><b>为什么要单开一个端点</b>:<c>s8-alerts</c> 是「最近 6 条」列表,带 <c>LIMIT 6</c>
-    /// 且 SQL 里<b>没有任何今日谓词</b>。前端拿它的 <c>length</c> 当「今日预警数」,于是
-    /// ① 真实今日 0 条、表里存着几个月前的旧行时,卡片照样显示非 0;
-    /// ② 今日真有 7 条以上时,卡片永远封顶在 6。两个方向都错。</para>
+    /// S8 看板预警计数。计数的是 ado_s8_exception 在窗口内新建的异常,不看当前是否已关闭。
     ///
-    /// <para><b>窗口口径</b>:未传日期 ⇒ <c>[今日 00:00, 明日 00:00)</c>(服务器自然日,
-    /// <c>isToday=true</c>);传了日期 ⇒ <c>[dateStart, dateEnd + 1 天)</c>,<c>isToday=false</c>,
-    /// 由前端把标题从「今日预警数」切成「区间预警数」。一律半开区间,与 <c>s8-alerts</c> 同构。</para>
+    /// <para><c>s8-alerts</c> 只返回未关闭的最近 6 条,不能拿它的长度当今日预警数。</para>
     ///
-    /// <para>维度过滤复用 <see cref="BuildS8AlertTextFilterSql"/>,不另起口径。</para>
+    /// <para>未传日期时窗口是服务器自然日 <c>[今日 00:00, 明日 00:00)</c>;传了日期则是半开区间。</para>
     /// </summary>
     [HttpGet("s8-alert-summary")]
     public async Task<IActionResult> GetS8AlertSummary(
@@ -195,6 +172,7 @@ LIMIT 6
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
@@ -204,44 +182,17 @@ LIMIT 6
         var windowEnd = (filter.DateEnd ?? filter.DateStart ?? DateTime.Today).Date.AddDays(1);
         if (windowEnd <= windowStart) windowEnd = windowStart.AddDays(1);
 
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT COUNT(*) AS Total,
-       IFNULL(SUM(CASE WHEN level_code = 'critical' THEN 1 ELSE 0 END), 0) AS Critical
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND alert_time >= @ws
-  AND alert_time < @we
-  {dimSql}
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8AlertSummaryRow>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ws = windowStart,
-            we = windowEnd,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
-
-        var row = rows.FirstOrDefault();
+        var row = await _s8HomeAlerts.GetCreatedSummaryAsync(tenantId, filter, windowStart, windowEnd, isToday);
         return Ok(new
         {
             windowStart = windowStart.ToString("yyyy-MM-dd"),
             windowEnd = windowEnd.AddDays(-1).ToString("yyyy-MM-dd"),
             isToday,
-            total = row?.Total ?? 0,
-            critical = row?.Critical ?? 0
+            total = row.Total,
+            critical = row.Critical
         });
     }
 
-    private sealed class S8AlertSummaryRow
-    {
-        public int Total { get; set; }
-        public int Critical { get; set; }
-    }
-
     [HttpGet("s8-home-trend")]
     public async Task<IActionResult> GetS8HomeTrend(
         [FromQuery] long factoryId = 1,
@@ -251,64 +202,50 @@ WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
         var end = (filter.DateEnd ?? DateTime.Today).Date;
         var start = (filter.DateStart ?? end.AddDays(-6)).Date;
         if ((end - start).TotalDays > 13) start = end.AddDays(-6);
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT DATE(alert_time) AS BizDate, COUNT(*) AS Cnt
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND alert_time >= @ds AND alert_time < DATE_ADD(@de, INTERVAL 1 DAY)
-  {dimSql}
-GROUP BY DATE(alert_time)
-ORDER BY BizDate
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8HomeTrendRow>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ds = start,
-            de = end,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
-        var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
-        var points = new List<object>();
-        for (var day = start; day <= end; day = day.AddDays(1))
-            points.Add(new { date = day.ToString("MM/dd"), count = byDate.GetValueOrDefault(day) });
+        var points = await _s8HomeAlerts.GetCreatedTrendAsync(tenantId, filter, start, end);
         return Ok(new
         {
             title = start.Month == end.Month && start.Year == end.Year
                 ? $"{start.Month}月异常趋势"
                 : $"{start:MM/dd}~{end:MM/dd} 异常趋势",
-            points
+            points = points.Select(x => new { date = x.Date.ToString("MM/dd"), count = x.Count })
         });
     }
 
-    private sealed class S8HomeTrendRow
+    [HttpGet("s8-module-lamps")]
+    public async Task<IActionResult> GetS8ModuleLamps()
     {
-        public DateTime BizDate { get; set; }
-        public int Cnt { get; set; }
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var now = DateTime.Now;
+        var payload = await _s8HomeAlerts.GetModuleLampsAsync(tenantId, now);
+        return Ok(new
+        {
+            asOf = payload.AsOf.ToString("yyyy-MM-dd HH:mm:ss"),
+            items = payload.Items.Select(x => new
+            {
+                code = x.Code,
+                name = x.Name,
+                businessState = x.BusinessState,
+                monitorState = x.MonitorState,
+                seriousCount = x.SeriousCount,
+                followCount = x.FollowCount,
+                openCount = x.OpenCount,
+                configuredRuleCount = x.ConfiguredRuleCount,
+                enabledRuleCount = x.EnabledRuleCount,
+                runnableRuleCount = x.RunnableRuleCount,
+                latestRunAt = x.LatestRunAt?.ToString("yyyy-MM-dd HH:mm:ss"),
+                monitorReasonCode = x.MonitorReasonCode
+            })
+        });
     }
 
-    /// <summary>S8 报警表无独立产品/订单/产线列,按报文与编码模糊匹配基础查询。</summary>
-    private static string BuildS8AlertTextFilterSql() => """
-  AND (@product IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @product, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @product, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @product, '%'))
-  AND (@orderNo IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @orderNo, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @orderNo, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @orderNo, '%'))
-  AND (@productionLine IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @productionLine, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @productionLine, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @productionLine, '%'))
-""";
-
     [HttpGet("module-detail")]
     public async Task<IActionResult> GetModuleDetail([FromQuery] string moduleCode = "S1", [FromQuery] long factoryId = 1)
     {

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdmMirrorUpsertService.cs

@@ -522,8 +522,8 @@ public sealed class MdmMirrorUpsertService : ITransient
     {
         var v = (raw ?? string.Empty).Trim();
         var u = v.ToUpperInvariant();
-        if (v is "在职" || u is "ACTIVE" or "ONJOB" or "ON_JOB") return "ACTIVE";
-        if (v is "离职" or "辞退" || u is "LEFT" or "LEAVE" or "RESIGNED" or "TERMINATED" or "QUIT") return "LEFT";
+        if (v is "在职" || u is "ACTIVE" or "ONJOB" or "ON_JOB" or "AC") return "ACTIVE";
+        if (v is "离职" or "辞退" || u is "LEFT" or "LEAVE" or "RESIGNED" or "TERMINATED" or "QUIT" or "DC") return "LEFT";
         if (v is "停用" || u is "INACTIVE" or "DISABLED" or "SUSPENDED") return "INACTIVE";
         return "UNKNOWN";
     }

+ 0 - 87
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConnectionImportService.cs

@@ -1,87 +0,0 @@
-using System.Data.Common;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using SqlSugar;
-
-namespace Admin.NET.Plugin.AiDOP.DataPlatform;
-
-/// <summary>
-/// 把本环境已注册的 t8_v5 连接抄进 mdp_source。只填 db_host 为空的行,不把口令写入迁移脚本或日志。
-/// </summary>
-public sealed class MdpSourceConnectionImportService : IHostedService
-{
-    private readonly ISqlSugarClient _db;
-    private readonly ILogger<MdpSourceConnectionImportService> _logger;
-
-    public MdpSourceConnectionImportService(ISqlSugarClient db, ILogger<MdpSourceConnectionImportService> logger)
-    {
-        _db = db;
-        _logger = logger;
-    }
-
-    public async Task StartAsync(CancellationToken cancellationToken)
-    {
-        try
-        {
-            if (!_db.AsTenant().IsAnyConnection("t8_v5"))
-                return;
-
-            var conn = _db.AsTenant().GetConnectionScope("t8_v5").CurrentConnectionConfig.ConnectionString;
-            if (string.IsNullOrWhiteSpace(conn))
-                return;
-
-            var builder = new DbConnectionStringBuilder { ConnectionString = conn };
-            string? Host() => First(builder, "Server", "Data Source", "Host");
-            var hostRaw = Host() ?? "";
-            var host = hostRaw;
-            int? port = null;
-            var comma = hostRaw.LastIndexOf(',');
-            if (comma > 0 && int.TryParse(hostRaw[(comma + 1)..], out var p))
-            {
-                host = hostRaw[..comma];
-                port = p;
-            }
-
-            var dbName = First(builder, "Database", "Initial Catalog");
-            var user = First(builder, "User Id", "Uid", "User ID");
-            var password = First(builder, "Password", "Pwd") ?? "";
-            if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(dbName) || string.IsNullOrWhiteSpace(user))
-                return;
-
-            var n = await _db.Ado.ExecuteCommandAsync(
-                """
-                UPDATE mdp_source
-                   SET db_type='SQLSERVER', db_host=@host, db_port=@port, db_name=@name,
-                       db_user=@user, db_password_enc=@pwd, conn_mode='EXTERNAL'
-                 WHERE source_code='T8_V5_SQLSERVER' AND IFNULL(db_host,'')=''
-                """,
-                new List<SugarParameter>
-                {
-                    new("@host", host),
-                    new("@port", port),
-                    new("@name", dbName),
-                    new("@user", user),
-                    new("@pwd", password)
-                });
-
-            if (n > 0)
-                _logger.LogInformation("已从已注册连接导入来源 T8_V5_SQLSERVER 的连接(口令已写入)");
-        }
-        catch (Exception ex)
-        {
-            _logger.LogWarning("来源连接导入跳过:{Message}", ex.Message);
-        }
-    }
-
-    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-
-    private static string? First(DbConnectionStringBuilder builder, params string[] keys)
-    {
-        foreach (var key in keys)
-        {
-            if (builder.TryGetValue(key, out var value) && value is string s && !string.IsNullOrWhiteSpace(s))
-                return s;
-        }
-        return null;
-    }
-}

+ 95 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourcePasswordResolver.cs

@@ -0,0 +1,95 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Core;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+public interface IMdpSourcePasswordResolver
+{
+    /// <summary>解析数据源口令。返回值不得写入日志、实体或数据库。</summary>
+    string Resolve(string sourceCode, string? storedSecret);
+}
+
+public sealed class MdpSourceSecretException : Exception
+{
+    public string Code { get; }
+
+    public MdpSourceSecretException(string code, string message) : base(message)
+    {
+        Code = code;
+    }
+}
+
+/// <summary>
+/// 口令只在建连时解析:环境变量优先,其次系统密文,最后才是迁移期明文。
+/// </summary>
+public sealed class MdpSourcePasswordResolver : IMdpSourcePasswordResolver, ITransient
+{
+    private readonly ILogger<MdpSourcePasswordResolver>? _logger;
+
+    public MdpSourcePasswordResolver(ILogger<MdpSourcePasswordResolver>? logger = null)
+    {
+        _logger = logger;
+    }
+
+    public static string EnvironmentVariableName(string sourceCode)
+    {
+        if (string.IsNullOrWhiteSpace(sourceCode))
+            throw new ArgumentException("sourceCode 不能为空", nameof(sourceCode));
+        var token = Regex.Replace(sourceCode.Trim().ToUpperInvariant(), "[^A-Z0-9]", "_");
+        return "AIDOP_MDP_SOURCE_" + token + "_PASSWORD";
+    }
+
+    public string Resolve(string sourceCode, string? storedSecret)
+        => ResolveCore(sourceCode, storedSecret, DecryptStored, _logger);
+
+    internal static string ResolveCore(
+        string sourceCode,
+        string? storedSecret,
+        Func<string, string?> decrypt,
+        ILogger? logger)
+    {
+        var envName = EnvironmentVariableName(sourceCode);
+        var fromEnv = Environment.GetEnvironmentVariable(envName);
+        if (!string.IsNullOrEmpty(fromEnv))
+            return fromEnv;
+
+        if (!string.IsNullOrEmpty(storedSecret))
+        {
+            if (TryDecrypt(storedSecret, decrypt, out var plain))
+                return plain;
+            logger?.LogWarning(
+                "mdp source {SourceCode} still uses a legacy plaintext secret. Set {EnvName} and clear the stored value.",
+                sourceCode, envName);
+            return storedSecret;
+        }
+
+        throw new MdpSourceSecretException(
+            "SECRET_MISSING",
+            $"源 {sourceCode} 未配置口令。请设置环境变量 {envName}。");
+    }
+
+    private static string? DecryptStored(string stored)
+    {
+        var decrypted = CryptogramUtil.Decrypt(stored);
+        return decrypted;
+    }
+
+    private static bool TryDecrypt(string stored, Func<string, string?> decrypt, out string plain)
+    {
+        plain = "";
+        try
+        {
+            var decrypted = decrypt(stored);
+            // 原样返回入参不是解密成功,按迁移期明文处理。
+            if (string.IsNullOrEmpty(decrypted) || string.Equals(decrypted, stored, StringComparison.Ordinal))
+                return false;
+            plain = decrypted;
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 82 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs

@@ -1,5 +1,6 @@
-using Admin.NET.Core;
+using System.Data.Common;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform;
@@ -12,10 +13,17 @@ public sealed class MdpSourceScopeFactory : ITransient
 {
     private const string ConfigIdPrefix = "mdp-src-";
     private readonly ISqlSugarClient _db;
+    private readonly IMdpSourcePasswordResolver _passwords;
+    private readonly ILogger<MdpSourceScopeFactory>? _logger;
 
-    public MdpSourceScopeFactory(ISqlSugarClient db)
+    public MdpSourceScopeFactory(
+        ISqlSugarClient db,
+        IMdpSourcePasswordResolver passwords,
+        ILogger<MdpSourceScopeFactory>? logger = null)
     {
         _db = db;
+        _passwords = passwords ?? throw new ArgumentNullException(nameof(passwords));
+        _logger = logger;
     }
 
     /// <summary>按 sourceCode 获取连接域;优先复用已注册 ConfigId。</summary>
@@ -114,17 +122,23 @@ public sealed class MdpSourceScopeFactory : ITransient
         };
     }
 
-    private static string BuildConnectionString(MdpSource source)
+    private string BuildConnectionString(MdpSource source)
     {
         if (string.IsNullOrWhiteSpace(source.DbHost) || string.IsNullOrWhiteSpace(source.DbName))
             throw new InvalidOperationException($"源 {source.SourceCode} 缺少 db_host/db_name");
 
-        var password = DecryptPassword(source.DbPasswordEnc);
+        var password = _passwords.Resolve(source.SourceCode, source.DbPasswordEnc);
         var port = source.DbPort;
         var user = source.DbUser ?? "";
-        var extra = string.IsNullOrWhiteSpace(source.DbExtraParams) ? "" : ";" + source.DbExtraParams.Trim().TrimStart(';');
-
-        return MapDbType(source.DbType) switch
+        var dbType = MapDbType(source.DbType);
+        var extraBody = SanitizeExtraParams(source.DbExtraParams, dbType, out var dropped);
+        if (dropped.Count > 0)
+            _logger?.LogWarning(
+                "源 {SourceCode} 的 db_extra_params 含非法关键字 {Keys},已忽略",
+                source.SourceCode, string.Join(",", dropped));
+        var extra = string.IsNullOrEmpty(extraBody) ? "" : ";" + extraBody;
+
+        return dbType switch
         {
             DbType.SqlServer =>
                 $"Server={source.DbHost}{(port is > 0 ? $",{port}" : "")};Database={source.DbName};User Id={user};Password={password};TrustServerCertificate=true;Encrypt=false{extra}",
@@ -138,18 +152,72 @@ public sealed class MdpSourceScopeFactory : ITransient
         };
     }
 
-    private static string DecryptPassword(string? enc)
+    /// <summary>
+    /// 剔掉不属于连接串的键:SqlSugar 的 ConfigId,以及本方法已经写死的键。
+    /// 按数据库类型分集合,避免一份黑名单误伤 MySQL / PostgreSQL / Oracle 的合法参数。
+    /// </summary>
+    internal static IReadOnlyList<string> IllegalExtraKeys(string? dbType, string? extra)
+    {
+        SanitizeExtraParams(extra, MapDbType(dbType), out var dropped);
+        return dropped;
+    }
+
+    internal static string? SanitizeExtraParams(string? extra, DbType dbType, out List<string> dropped)
     {
-        if (string.IsNullOrEmpty(enc)) return "";
+        dropped = new List<string>();
+        if (string.IsNullOrWhiteSpace(extra)) return null;
+
+        DbConnectionStringBuilder parsed;
         try
         {
-            var plain = CryptogramUtil.Decrypt(enc);
-            return string.IsNullOrEmpty(plain) ? enc : plain;
+            parsed = new DbConnectionStringBuilder { ConnectionString = extra.Trim().TrimStart(';') };
+        }
+        catch (ArgumentException)
+        {
+            dropped.Add("(unparseable)");
+            return null;
         }
-        catch
+
+        var reserved = ReservedConnectionKeys(dbType);
+        var kept = new DbConnectionStringBuilder();
+        foreach (string key in parsed.Keys)
         {
-            // 明文或非本系统密文时原样使用(与 Database.json EnableConnEncrypt=false 一致)
-            return enc;
+            if (IsSqlSugarOnlyKey(key) || reserved.Contains(key))
+                dropped.Add(key);
+            else
+                kept[key] = parsed[key];
         }
+
+        return kept.Count == 0 ? null : kept.ConnectionString;
     }
+
+    private static bool IsSqlSugarOnlyKey(string key) =>
+        string.Equals(key, "ConfigId", StringComparison.OrdinalIgnoreCase);
+
+    private static HashSet<string> ReservedConnectionKeys(DbType dbType) => dbType switch
+    {
+        DbType.SqlServer => new(StringComparer.OrdinalIgnoreCase)
+        {
+            "Server", "Data Source", "Address", "Addr", "Network Address",
+            "Database", "Initial Catalog",
+            "User Id", "UID", "User",
+            "Password", "PWD",
+            "TrustServerCertificate", "Encrypt"
+        },
+        DbType.MySql => new(StringComparer.OrdinalIgnoreCase)
+        {
+            "Server", "Host", "Port", "Database", "Uid", "User Id", "Pwd", "Password",
+            "CharSet", "AllowLoadLocalInfile"
+        },
+        DbType.PostgreSQL => new(StringComparer.OrdinalIgnoreCase)
+        {
+            "Host", "Port", "Database", "Username", "User Id", "Password"
+        },
+        DbType.Oracle => new(StringComparer.OrdinalIgnoreCase)
+        {
+            "Data Source", "User Id", "Password"
+        },
+        _ => new(StringComparer.OrdinalIgnoreCase)
+    };
+
 }

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NativeNeutralProjectionService.cs

@@ -202,8 +202,8 @@ public sealed class NativeNeutralProjectionService : ITransient
               COALESCE(NULLIF(e.JobTitle,''), NULLIF(e.WorkCtr,'')),
               CASE
                 WHEN e.DateTerminated IS NOT NULL AND e.DateTerminated <= CURDATE() THEN 'LEFT'
-                WHEN e.EmploymentStatus IN ('在职') OR UPPER(IFNULL(e.EmploymentStatus,'')) IN ('ACTIVE','ONJOB','ON_JOB') THEN 'ACTIVE'
-                WHEN e.EmploymentStatus IN ('离职','辞退') OR UPPER(IFNULL(e.EmploymentStatus,'')) IN ('LEFT','LEAVE','RESIGNED','TERMINATED','QUIT') THEN 'LEFT'
+                WHEN e.EmploymentStatus IN ('在职') OR UPPER(IFNULL(e.EmploymentStatus,'')) IN ('ACTIVE','ONJOB','ON_JOB','AC') THEN 'ACTIVE'
+                WHEN e.EmploymentStatus IN ('离职','辞退') OR UPPER(IFNULL(e.EmploymentStatus,'')) IN ('LEFT','LEAVE','RESIGNED','TERMINATED','QUIT','DC') THEN 'LEFT'
                 WHEN e.EmploymentStatus IN ('停用') OR UPPER(IFNULL(e.EmploymentStatus,'')) IN ('INACTIVE','DISABLED','SUSPENDED') THEN 'INACTIVE'
                 ELSE 'UNKNOWN' END,
               e.EmploymentStatus,

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/T8BaseInboundMdpSyncService.cs

@@ -7,7 +7,7 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform;
 /// <summary>
 /// T8 基表双模式入站(S5/S6/S7 KPI 共用贴源+标准层)。
 /// 照抄 S6_REPORT 范式:执行器 → mdp_stg_t8_* → transform → mdp_std_t8_*。
-/// 源:mdp_source=T8_V5_SQLSERVER(MdpSourceScopeFactory 复用 Database.json 的 t8_v5 只读连接)。
+/// 源:mdp_source=T8_V5_SQLSERVER。口令由环境变量 AIDOP_MDP_SOURCE_T8_V5_SQLSERVER_PASSWORD 提供,不读 Database.json。
 /// 实体:mdp_entity T8_KC_TZ_HEAD / T8_KC_TZ_LIST / T8_KC_DD_HEAD / T8_KC_DD_LIST / T8_KC_ZJ_LIST / T8_SYS_PELIST
 ///       (种子把 target_table_name 指向对应 mdp_stg_t8_*、sync_mode=FULL、biz_key_expr=Id)。
 /// 只读源、只写 mdp;不写 T8;FULL 全量(源表极小、re-upsert 刷新可带回字段/作废标志更新)。

+ 53 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/ShipTransNeutralProjection.cs

@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
@@ -33,11 +34,13 @@ public sealed class ShipTransNeutralProjection : ITransient
 
     private readonly ISqlSugarClient _db;
     private readonly MdpNeutralSourceGate _gate;
+    private readonly ILogger _logger;
 
-    public ShipTransNeutralProjection(ISqlSugarClient db, MdpNeutralSourceGate gate)
+    public ShipTransNeutralProjection(ISqlSugarClient db, MdpNeutralSourceGate gate, ILoggerFactory loggerFactory)
     {
         _db = db;
         _gate = gate;
+        _logger = loggerFactory.CreateLogger(nameof(ShipTransNeutralProjection));
     }
 
     public async Task<int> ProjectAsync(long tenantId, string? batchId, CancellationToken cancellationToken = default)
@@ -118,27 +121,55 @@ public sealed class ShipTransNeutralProjection : ITransient
             new SugarParameter("@tid", tenantId),
             new SugarParameter("@batch", batchId ?? ""));
 
-        await _db.Ado.ExecuteCommandAsync(
-            $"""
-            INSERT INTO mdp_source_gate_log
-              (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
-            SELECT @tid, 'INV_TRANS', 'DOPDEMORQ_SQLSERVER', 'SO_LINE_NOT_FOUND',
-                   COUNT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),''), ':', {OrdLineExpr})) AS asn_rows,
-                   LEFT(GROUP_CONCAT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),''), ':', {OrdLineExpr})), 500),
-                   @batch
-            FROM mdp_stg_ship_trans s
-            LEFT JOIN mdp_std_work_order_line wol
-              ON wol.tenant_id=@tid AND wol.source_system='AIDOP_NATIVE' AND wol.doc_type='SALES_ORDER'
-             AND wol.order_no=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null')
-             AND wol.line_no=CAST({OrdLineExpr} AS SIGNED)
-            WHERE {AsnStagingFilter}
-              AND wol.id IS NULL
-              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.CustItem')),'null') IS NULL
-              AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ContainerItem')),'null') IS NULL
-            HAVING asn_rows>0
-            """,
-            new SugarParameter("@tid", tenantId),
-            new SugarParameter("@batch", batchId ?? ""));
+        try
+        {
+            // gate log 是诊断记录,写失败不得中断发运投影。
+            // 元素是 OrdNbr:OrdLine,按 70 字节估算,12 条低于 group_concat_max_len=1024。
+            // 外层 LEFT(...,500) 只是列宽兜底。row_count 仍是全量 COUNT(DISTINCT ...)。
+            await _db.Ado.ExecuteCommandAsync(
+                $"""
+                INSERT INTO mdp_source_gate_log
+                  (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
+                SELECT @tid, 'INV_TRANS', 'DOPDEMORQ_SQLSERVER', 'SO_LINE_NOT_FOUND',
+                       COUNT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),''), ':', {OrdLineExpr})) AS asn_rows,
+                       LEFT((
+                         SELECT GROUP_CONCAT(x.sample_key ORDER BY x.sample_key SEPARATOR ',')
+                         FROM (
+                           SELECT DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s2.raw_data,'$.OrdNbr')),''), ':', {OrdLineExpr.Replace("s.raw_data", "s2.raw_data")}) AS sample_key
+                           FROM mdp_stg_ship_trans s2
+                           LEFT JOIN mdp_std_work_order_line wol2
+                             ON wol2.tenant_id=@tid AND wol2.source_system='AIDOP_NATIVE' AND wol2.doc_type='SALES_ORDER'
+                            AND wol2.order_no=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s2.raw_data,'$.OrdNbr')),'null')
+                            AND wol2.line_no=CAST({OrdLineExpr.Replace("s.raw_data", "s2.raw_data")} AS SIGNED)
+                           WHERE {AsnStagingFilter.Replace("s.tenant_id", "s2.tenant_id").Replace("s.source_table", "s2.source_table").Replace("s.raw_data", "s2.raw_data")}
+                             AND wol2.id IS NULL
+                             AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s2.raw_data,'$.CustItem')),'null') IS NULL
+                             AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s2.raw_data,'$.ContainerItem')),'null') IS NULL
+                           ORDER BY sample_key
+                           LIMIT 12
+                         ) x
+                       ), 500),
+                       @batch
+                FROM mdp_stg_ship_trans s
+                LEFT JOIN mdp_std_work_order_line wol
+                  ON wol.tenant_id=@tid AND wol.source_system='AIDOP_NATIVE' AND wol.doc_type='SALES_ORDER'
+                 AND wol.order_no=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null')
+                 AND wol.line_no=CAST({OrdLineExpr} AS SIGNED)
+                WHERE {AsnStagingFilter}
+                  AND wol.id IS NULL
+                  AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.CustItem')),'null') IS NULL
+                  AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ContainerItem')),'null') IS NULL
+                HAVING asn_rows>0
+                """,
+                new SugarParameter("@tid", tenantId),
+                new SugarParameter("@batch", batchId ?? ""));
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "[ShipTransNeutralProjection] SO_LINE_NOT_FOUND gate log 写入失败,不影响发运投影。tenant={Tenant}",
+                tenantId);
+        }
 
         await _db.Ado.ExecuteCommandAsync(
             $"""

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs

@@ -51,6 +51,9 @@ public class AdoSmartOpsKpiMaster : ITenantIdFilter
     [SugarColumn(ColumnDescription = "统计频率", Length = 50, IsNullable = true)]
     public string? StatFrequency { get; set; }
 
+    [SugarColumn(ColumnDescription = "首页数据新鲜度SLA(自然日)", IsNullable = true)]
+    public int? FreshnessSlaDays { get; set; }
+
     [SugarColumn(ColumnDescription = "责任部门", Length = 200, IsNullable = true)]
     public string? Department { get; set; }
 

+ 22 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs

@@ -159,6 +159,14 @@ public static class AidopKpiMasterSeed
             "1、每月统计当月物料仓储人效。\n2、例如:3月上线的工单物料共10000个/仓储人数4人,则该物料仓储人效=10000/4=2500个/人。",
             "S5-物料仓储", "天", "仓储物流部",
             "", "");
+        // 历史上只靠 1.0.187.sql 补进个别租户,种子缺这一条,之后每个新租户的九宫格都看不到它。
+        // 阈值走 Ins 对 lower_is_better 的默认(黄 110 / 红 120),与 TenantId=0 模板一致。
+        Ins(db, tenantId, "S5_L1_004", "S5", 1, null, "品类物料库存周转", "天", "lower_is_better", 20, true,
+            "S5 物料仓储:按品类的物料库存周转天数",
+            "D1 / D2 × 30",
+            "D1 为月均库存金额,D2 为出库成本,周转天数 = D1 / D2 × 30",
+            "S5-物料仓储", "天", "仓储物流部",
+            "", "");
         var l1_s6_1 = Ins(db, tenantId, "S6_L1_001", "S6", 1, null, "订单制造周期(天)", "天", "lower_is_better", 20, true,
             "从订单投产到订单完工的时长",
             "每个订单的制造周期=D2-D1",
@@ -1122,6 +1130,19 @@ public static class AidopKpiMasterSeed
         return id;
     }
 
+    /// <summary>
+    /// 新建指标的默认新鲜度。月度口径按 code 固定 45 天,实时 1 天,其余 7 天。
+    /// 手工补的指标若漏填,接口返回 POLICY_MISSING,不会在这里被当成新鲜。
+    /// </summary>
+    public static int ResolveFreshnessSlaDays(string metricCode, string? statFrequency)
+    {
+        if (metricCode is "S5_L1_003" or "S5_L1_004" or "S6_L1_003" or "S7_L1_003" or "S9_L1_001")
+            return 45;
+        if (string.Equals(statFrequency, "实时", StringComparison.Ordinal))
+            return 1;
+        return 7;
+    }
+
     private static long Ins(ISqlSugarClient db, long tenantId, string code, string module, int level, long? parentId,
         string name, string unit, string direction, int sortNo, bool isHomePage,
         string desc, string formula, string calcRule,
@@ -1144,6 +1165,7 @@ public static class AidopKpiMasterSeed
             CalcRule = string.IsNullOrEmpty(calcRule) ? null : calcRule,
             DataSource = string.IsNullOrEmpty(dataSource) ? null : dataSource,
             StatFrequency = string.IsNullOrEmpty(statFreq) ? null : statFreq,
+            FreshnessSlaDays = ResolveFreshnessSlaDays(code, statFreq),
             Department = string.IsNullOrEmpty(dept) ? null : dept,
             DopFields = string.IsNullOrEmpty(dopFields) ? null : dopFields,
             Remark = string.IsNullOrEmpty(remark) ? null : remark,

+ 52 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs

@@ -43,10 +43,14 @@ public class MdpSourceHealthCheckJob : IJob
             {
                 if (string.Equals(src.SourceType, "DB", StringComparison.OrdinalIgnoreCase))
                 {
+                    var illegal = MdpSourceScopeFactory.IllegalExtraKeys(src.DbType, src.DbExtraParams);
+                    var illegalNote = illegal.Count == 0
+                        ? null
+                        : $"db_extra_params 含非法关键字 {string.Join(",", illegal)},已忽略";
                     var scopeDb = await scopeFactory.GetScopeAsync(src.SourceCode, stoppingToken);
                     await scopeDb.Ado.GetIntAsync("SELECT 1");
                     src.HealthStatus = 1;
-                    src.HealthMsg = "OK";
+                    src.HealthMsg = illegalNote == null ? "OK" : $"OK({illegalNote})";
                     ok++;
                 }
                 else if (string.Equals(src.SourceType, "API", StringComparison.OrdinalIgnoreCase))
@@ -75,7 +79,19 @@ public class MdpSourceHealthCheckJob : IJob
             catch (Exception ex)
             {
                 src.HealthStatus = 0;
-                src.HealthMsg = ex.Message.Length > 480 ? ex.Message[..480] : ex.Message;
+                var code = ClassifyHealthFailure(ex);
+                var illegal = string.Equals(src.SourceType, "DB", StringComparison.OrdinalIgnoreCase)
+                    ? MdpSourceScopeFactory.IllegalExtraKeys(src.DbType, src.DbExtraParams)
+                    : Array.Empty<string>();
+                var prefix = illegal.Count == 0
+                    ? ""
+                    : $"db_extra_params 含非法关键字 {string.Join(",", illegal)},已忽略。";
+                src.HealthMsg = prefix + code + " " + SafeHealthMessage(ex.Message);
+                if (src.HealthMsg.Length > 480)
+                    src.HealthMsg = src.HealthMsg[..480];
+                _logger.LogWarning(
+                    "[MdpSourceHealthCheckJob] source={SourceCode} code={Code}",
+                    src.SourceCode, code);
                 fail++;
             }
 
@@ -209,4 +225,38 @@ public class MdpSourceHealthCheckJob : IJob
 
     private static string Truncate(string s, int max) =>
         string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max];
+
+    internal static string ClassifyHealthFailure(Exception ex)
+    {
+        if (ex is MdpSourceSecretException secret)
+            return secret.Code;
+        for (var current = ex; current != null; current = current.InnerException)
+        {
+            var number = current.GetType().GetProperty("Number")?.GetValue(current);
+            if (number is int n && (n == 1045 || n == 18456))
+                return "AUTH_FAILED";
+            var msg = current.Message ?? "";
+            if (msg.Contains("登录失败", StringComparison.Ordinal)
+                || msg.Contains("Login failed", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("Access denied", StringComparison.OrdinalIgnoreCase))
+                return "AUTH_FAILED";
+            if (msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("Unable to connect", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("network-related", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("连接", StringComparison.Ordinal))
+                return "NETWORK_FAILED";
+        }
+        return "FAILED";
+    }
+
+    internal static string SafeHealthMessage(string? message)
+    {
+        var text = message ?? "";
+        if (text.Contains("Password=", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("Pwd=", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("Server=", StringComparison.OrdinalIgnoreCase))
+            return "连接失败,详情见运行日志";
+        return text.Length > 400 ? text[..400] : text;
+    }
 }

+ 50 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryInboundLockGuard.cs

@@ -36,6 +36,12 @@ public sealed class InventoryInboundLockGuard : IAsyncDisposable
     /// <summary>取锁等待毫秒数:0 = 立即返回,不排队。</summary>
     private const int NoWaitMilliseconds = 0;
 
+    /// <summary>重算链路等待同一把库存锁的上限。后台增量仍走非阻塞 <see cref="TryAcquireAsync"/>。</summary>
+    public static readonly TimeSpan DefaultWaitTimeout = TimeSpan.FromMinutes(10);
+
+    /// <summary>重算链路轮询 <c>GET_LOCK(key, 0)</c> 的间隔,避免长时间占用错误连接。</summary>
+    public static readonly TimeSpan DefaultPollInterval = TimeSpan.FromSeconds(2);
+
     /// <summary>锁语句自身的命令超时(秒);只跑 GET_LOCK/RELEASE_LOCK,不需要长超时。</summary>
     private const int LockCommandTimeoutSeconds = 10;
 
@@ -75,14 +81,54 @@ public sealed class InventoryInboundLockGuard : IAsyncDisposable
         _lockKey = lockKey;
     }
 
+    /// <summary>
+    /// 有界等待同一把库存锁。生产调用使用 10 分钟上限、每 2 秒非阻塞轮询。
+    /// 超时抛 <see cref="TimeoutException"/>,消息固定为 <c>inventory materialization lock busy</c>。
+    /// </summary>
+    public static Task<InventoryInboundLockGuard> AcquireAsync(
+        ISqlSugarClient db,
+        ILogger logger,
+        string lockKey = LockKey,
+        CancellationToken cancellationToken = default)
+        => AcquireAsync(db, logger, lockKey, DefaultWaitTimeout, DefaultPollInterval, cancellationToken);
+
+    /// <summary>测试可传入更短的等待窗口;生产入口不暴露这两个参数。</summary>
+    public static async Task<InventoryInboundLockGuard> AcquireAsync(
+        ISqlSugarClient db,
+        ILogger logger,
+        string lockKey,
+        TimeSpan waitTimeout,
+        TimeSpan pollInterval,
+        CancellationToken cancellationToken = default)
+    {
+        if (db == null) throw new ArgumentNullException(nameof(db));
+        if (waitTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(waitTimeout));
+        if (pollInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(pollInterval));
+
+        var deadline = DateTime.UtcNow + waitTimeout;
+        while (true)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            var guard = await TryAcquireAsync(db, logger, lockKey, cancellationToken);
+            if (guard.Acquired)
+                return guard;
+
+            await guard.DisposeAsync();
+            if (DateTime.UtcNow >= deadline)
+                throw new TimeoutException("inventory materialization lock busy");
+
+            var remaining = deadline - DateTime.UtcNow;
+            var delay = remaining < pollInterval ? remaining : pollInterval;
+            if (delay > TimeSpan.Zero)
+                await Task.Delay(delay, cancellationToken);
+        }
+    }
+
     /// <summary>
     /// 尝试取锁;无论成功与否都返回一个 guard,调用方以 <see cref="Acquired"/> 判定,
     /// 并始终以 <c>await using</c> 持有(未取到锁的 guard 释放时什么都不做)。
+    /// 后台增量使用本方法,抢不到立即返回,不排队。
     /// </summary>
-    /// <param name="db">主库客户端,仅用于读取连接配置,不在其上取锁。</param>
-    /// <param name="logger">日志。</param>
-    /// <param name="lockKey">咨询锁键;默认 <see cref="LockKey"/>,测试可传独立键避免打扰生产链路。</param>
-    /// <param name="cancellationToken">取消令牌。</param>
     public static async Task<InventoryInboundLockGuard> TryAcquireAsync(
         ISqlSugarClient db,
         ILogger logger,

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMaterializationRetry.cs

@@ -0,0 +1,39 @@
+using MySqlConnector;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// 库存标准层物化 SQL 的死锁重试。只重试 MySQL 1213 / 1205,最多 3 次。
+/// </summary>
+public static class InventoryMaterializationRetry
+{
+    public const int MaxAttempts = 3;
+    public static readonly int[] RetryDelayMilliseconds = [250, 750];
+
+    public static bool IsRetryable(Exception ex)
+    {
+        for (var current = ex; current != null; current = current.InnerException)
+        {
+            if (current is MySqlException mysql && (mysql.Number == 1213 || mysql.Number == 1205))
+                return true;
+        }
+        return false;
+    }
+
+    public static async Task<T> ExecuteAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
+    {
+        if (action == null) throw new ArgumentNullException(nameof(action));
+        for (var attempt = 1; ; attempt++)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            try
+            {
+                return await action();
+            }
+            catch (Exception ex) when (IsRetryable(ex) && attempt < MaxAttempts)
+            {
+                await Task.Delay(RetryDelayMilliseconds[attempt - 1], cancellationToken);
+            }
+        }
+    }
+}

+ 59 - 23
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

@@ -291,13 +291,17 @@ public sealed class InventoryMdpSyncService : ITransient
                 slices.Add((formalSourceTenantId, sourceCode));
         }
 
+        // 重算入口与半小时贴源同步写同一张标准表。后台同步用非阻塞锁,
+        // 本入口必须等到锁释放,不能把「没物化」当成 0 行成功。
+        await using var guard = await InventoryInboundLockGuard.AcquireAsync(
+            _db, _logger, cancellationToken: cancellationToken);
         using var xformTimeout = WithLongCommandTimeout();
         var affected = 0;
         foreach (var (sourceTenantId, sourceSystem) in slices)
         {
             cancellationToken.ThrowIfCancellationRequested();
             affected += await MaterializeInvTransStdAsync(
-                sourceTenantId, tenantId, batchId: null, asOf, historyFrom, sourceSystem);
+                sourceTenantId, tenantId, batchId: null, asOf, historyFrom, sourceSystem, cancellationToken);
         }
         await _requiredColumns.AssertAsync(
             tenantId, "INV_TRANS", "mdp_std_inv_trans", batchId: null, cancellationToken);
@@ -753,7 +757,8 @@ public sealed class InventoryMdpSyncService : ITransient
     /// <param name="replaceMode">true=已由 MdpStdFullReplace 删除正式切片,直接 INSERT;false=增量 UPSERT。</param>
     private async Task<int> MaterializeInvTransStdAsync(
         long sourceTenantId, long targetTenantId, string? batchId,
-        DateTime asOf, DateTime historyFrom, string sourceSystem)
+        DateTime asOf, DateTime historyFrom, string sourceSystem,
+        CancellationToken cancellationToken = default)
     {
         if (!await _neutralGate.AllowsAsync(targetTenantId, "INV_TRANS", sourceSystem))
             return 0;
@@ -866,7 +871,8 @@ public sealed class InventoryMdpSyncService : ITransient
         if (!string.IsNullOrWhiteSpace(batchId))
             ps.Add(new SugarParameter("@BatchId", batchId));
 
-        var affected = await _db.Ado.ExecuteCommandAsync(sql, ps);
+        var affected = await InventoryMaterializationRetry.ExecuteAsync(
+            () => _db.Ado.ExecuteCommandAsync(sql, ps), cancellationToken);
         await RegisterUnmappedAsync(targetTenantId, sourceSystem, batchId);
         await LogWorkOrderNotFoundAsync(targetTenantId, sourceSystem, batchId);
         if (await _neutralGate.AllowsAsync(targetTenantId, "INV_BAL_MONTHLY", sourceSystem, syncBatchId: batchId))
@@ -888,26 +894,56 @@ public sealed class InventoryMdpSyncService : ITransient
             return;
         if (!await _neutralGate.AllowsAsync(targetTenantId, "WO_LINE_PROD", MdpSourceIdentity.Native, syncBatchId: batchId))
             return;
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            INSERT INTO mdp_source_gate_log
-              (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
-            SELECT @tid, 'WO_LINE_PROD', @src, 'WO_NOT_FOUND', COUNT(*),
-                   LEFT(GROUP_CONCAT(DISTINCT t.work_ord), 500), @batch
-            FROM mdp_std_inv_trans t
-            WHERE t.tenant_id=@tid AND t.source_system=@src
-              AND t.src_trans_type_raw IN ('rct-wo','iss-wo')
-              AND IFNULL(t.work_ord,'')<>''
-              AND NOT EXISTS (
-                SELECT 1 FROM mdp_std_work_order_line w
-                WHERE w.tenant_id=t.tenant_id AND w.source_system='AIDOP_NATIVE'
-                  AND w.doc_type='PROD_TASK' AND w.order_no=t.work_ord
-              )
-            HAVING COUNT(*)>0
-            """,
-            new SugarParameter("@tid", targetTenantId),
-            new SugarParameter("@src", sourceSystem),
-            new SugarParameter("@batch", batchId ?? ""));
+        try
+        {
+            // gate log 是诊断记录,写失败不得中断库存流水(参照 MdpDbPullExecutor 作用域探针)。
+            // 采样先 LIMIT 再 GROUP_CONCAT:group_concat_max_len 默认 1024,直接拼会在 STRICT 模式下抛错。
+            // work_ord 按 32 字节估算,20 条(含分隔符)低于 1024。外层 LEFT(...,500) 只是列宽兜底,不是防截断。
+            // row_count 仍是全量 COUNT(*),不受采样条数影响。
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO mdp_source_gate_log
+                  (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
+                SELECT @tid, 'WO_LINE_PROD', @src, 'WO_NOT_FOUND', COUNT(*),
+                       LEFT((
+                         SELECT GROUP_CONCAT(s.work_ord ORDER BY s.work_ord SEPARATOR ',')
+                         FROM (
+                           SELECT DISTINCT t2.work_ord AS work_ord
+                           FROM mdp_std_inv_trans t2
+                           WHERE t2.tenant_id=@tid AND t2.source_system=@src
+                             AND t2.src_trans_type_raw IN ('rct-wo','iss-wo')
+                             AND IFNULL(t2.work_ord,'')<>''
+                             AND NOT EXISTS (
+                               SELECT 1 FROM mdp_std_work_order_line w
+                               WHERE w.tenant_id=t2.tenant_id AND w.source_system='AIDOP_NATIVE'
+                                 AND w.doc_type='PROD_TASK' AND w.order_no=t2.work_ord
+                             )
+                           ORDER BY t2.work_ord
+                           LIMIT 20
+                         ) s
+                       ), 500),
+                       @batch
+                FROM mdp_std_inv_trans t
+                WHERE t.tenant_id=@tid AND t.source_system=@src
+                  AND t.src_trans_type_raw IN ('rct-wo','iss-wo')
+                  AND IFNULL(t.work_ord,'')<>''
+                  AND NOT EXISTS (
+                    SELECT 1 FROM mdp_std_work_order_line w
+                    WHERE w.tenant_id=t.tenant_id AND w.source_system='AIDOP_NATIVE'
+                      AND w.doc_type='PROD_TASK' AND w.order_no=t.work_ord
+                  )
+                HAVING COUNT(*)>0
+                """,
+                new SugarParameter("@tid", targetTenantId),
+                new SugarParameter("@src", sourceSystem),
+                new SugarParameter("@batch", batchId ?? ""));
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex,
+                "[InventoryMdpSync] WO_NOT_FOUND gate log 写入失败,不影响库存流水。tenant={Tenant}",
+                targetTenantId);
+        }
     }
 
     /// <summary>

+ 290 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs

@@ -0,0 +1,290 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 九宫格 S8 的当前异常与模块灯态。异常权威是 ado_s8_exception,监控权威是 ado_s8_watch_rule。
+/// </summary>
+public sealed class S8HomeAlertQueryService : ITransient
+{
+    public const int RecentLimit = 6;
+    public const int MinFreshWindowSeconds = 600;
+
+    /// <summary>列表、汇总、趋势共用的业务维度过滤。只匹配异常权威表上的可追溯字段。</summary>
+    public const string TextFilterSql = """
+  AND (@product IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @product, '%'))
+  AND (@orderNo IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @orderNo, '%'))
+  AND (@productionLine IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @productionLine, '%'))
+""";
+
+    private const string ActiveStatusSql = "status NOT IN ('CLOSED','REJECTED','RECOVERED')";
+
+    private readonly ISqlSugarClient _db;
+
+    public S8HomeAlertQueryService(ISqlSugarClient db) => _db = db;
+
+    public async Task<List<S8HomeAlertItem>> GetRecentAsync(long tenantId, SmartOpsDashboardFilter filter)
+    {
+        var sql = $"""
+SELECT DATE_FORMAT(COALESCE(last_detected_at, created_at), '%H:%i') AS Time,
+       IFNULL(NULLIF(stage_code,''), IFNULL(NULLIF(module_code,''), 'S8')) AS Module,
+       IFNULL(NULLIF(title,''), IFNULL(NULLIF(description,''), '异常告警')) AS Message,
+       severity AS Severity
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0 AND {ActiveStatusSql}
+  AND (@ds IS NULL OR created_at >= @ds)
+  AND (@de IS NULL OR created_at < DATE_ADD(@de, INTERVAL 1 DAY))
+  {TextFilterSql}
+ORDER BY COALESCE(last_detected_at, created_at) DESC, id DESC
+LIMIT {RecentLimit}
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ds"] = filter.DateStart;
+        args["de"] = filter.DateEnd;
+        var rows = await _db.Ado.SqlQueryAsync<S8HomeAlertRaw>(sql, args);
+        return rows.Select(MapAlert).ToList();
+    }
+
+    public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync(
+        long tenantId, SmartOpsDashboardFilter filter, DateTime windowStart, DateTime windowEnd, bool isToday)
+    {
+        var sql = $"""
+SELECT COUNT(*) AS Total,
+       IFNULL(SUM(CASE WHEN UPPER(IFNULL(severity,'')) IN ('SERIOUS','HIGH','CRITICAL') THEN 1 ELSE 0 END), 0) AS Critical
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND created_at >= @ws AND created_at < @we
+  {TextFilterSql}
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ws"] = windowStart;
+        args["we"] = windowEnd;
+        var row = (await _db.Ado.SqlQueryAsync<S8HomeSummaryRaw>(sql, args)).FirstOrDefault();
+        return new S8HomeAlertSummary(windowStart, windowEnd, isToday, row?.Total ?? 0, row?.Critical ?? 0);
+    }
+
+    public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync(
+        long tenantId, SmartOpsDashboardFilter filter, DateTime start, DateTime end)
+    {
+        var sql = $"""
+SELECT DATE(created_at) AS BizDate, COUNT(*) AS Cnt
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND created_at >= @ds AND created_at < DATE_ADD(@de, INTERVAL 1 DAY)
+  {TextFilterSql}
+GROUP BY DATE(created_at)
+ORDER BY BizDate
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ds"] = start;
+        args["de"] = end;
+        var rows = await _db.Ado.SqlQueryAsync<S8HomeTrendRaw>(sql, args);
+        var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
+        var points = new List<S8HomeTrendPoint>();
+        for (var day = start.Date; day <= end.Date; day = day.AddDays(1))
+            points.Add(new S8HomeTrendPoint(day, byDate.GetValueOrDefault(day)));
+        return points;
+    }
+
+    public async Task<S8ModuleLampPayload> GetModuleLampsAsync(long tenantId, DateTime now)
+    {
+        const string exceptionSql = """
+SELECT stage_code AS StageCode, severity AS Severity
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND status NOT IN ('CLOSED','REJECTED','RECOVERED')
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+""";
+        const string ruleSql = """
+SELECT stage_code AS StageCode, enabled AS Enabled, poll_interval_seconds AS PollIntervalSeconds,
+       last_status AS LastStatus, last_run_at AS LastRunAt, paused_until AS PausedUntil
+FROM ado_s8_watch_rule
+WHERE tenant_id=@tenantId
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+""";
+        var exceptions = await _db.Ado.SqlQueryAsync<S8StageSeverityRaw>(exceptionSql, new { tenantId });
+        var ruleRows = await _db.Ado.SqlQueryAsync<S8WatchRuleRaw>(ruleSql, new { tenantId });
+        var rules = ruleRows.Select(x => new S8WatchRuleSignal(
+            x.StageCode ?? "",
+            x.Enabled,
+            x.PollIntervalSeconds,
+            x.LastStatus,
+            x.LastRunAt,
+            x.PausedUntil)).ToList();
+        var items = S8ModuleCode.All.Select(code =>
+        {
+            var stageRows = exceptions.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
+            var serious = stageRows.Count(x => S8SeverityCode.IsSerious(x.Severity));
+            var follow = stageRows.Count - serious;
+            var stageRules = rules.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
+            var decision = S8ModuleLampPolicy.Decide(serious, follow, stageRules, now);
+            return new S8ModuleLampItem(
+                code,
+                S8ModuleCode.Label(code),
+                decision.BusinessState,
+                decision.MonitorState,
+                serious,
+                follow,
+                stageRows.Count,
+                stageRules.Count,
+                stageRules.Count(x => x.Enabled),
+                stageRules.Count(x => S8ModuleLampPolicy.IsRunnable(x, now)),
+                stageRules.Where(x => x.LastRunAt.HasValue).Select(x => x.LastRunAt).Max(),
+                decision.MonitorReasonCode);
+        }).ToList();
+        return new S8ModuleLampPayload(now, items);
+    }
+
+    private static S8HomeAlertItem MapAlert(S8HomeAlertRaw row)
+    {
+        var serious = S8SeverityCode.IsSerious(row.Severity);
+        return new S8HomeAlertItem(
+            row.Time ?? "",
+            string.IsNullOrWhiteSpace(row.Module) ? "S8" : row.Module,
+            string.IsNullOrWhiteSpace(row.Message) ? "异常告警" : row.Message,
+            serious ? "critical" : "medium",
+            serious ? "严重" : "关注");
+    }
+
+    private static Dictionary<string, object?> FilterArgs(long tenantId, SmartOpsDashboardFilter filter) => new()
+    {
+        ["tenantId"] = tenantId,
+        ["product"] = BlankToNull(filter.Product),
+        ["orderNo"] = BlankToNull(filter.OrderNo),
+        ["productionLine"] = BlankToNull(filter.ProductionLine)
+    };
+
+    private static string? BlankToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+}
+
+public static class S8ModuleLampPolicy
+{
+    public static int FreshWindowSeconds(int pollIntervalSeconds) =>
+        Math.Max(pollIntervalSeconds * 2, S8HomeAlertQueryService.MinFreshWindowSeconds);
+
+    public static bool IsRunnable(S8WatchRuleSignal rule, DateTime now) =>
+        rule.Enabled && (!rule.PausedUntil.HasValue || rule.PausedUntil.Value <= now);
+
+    public static S8ModuleLampDecision Decide(
+        int seriousCount, int followCount, IReadOnlyList<S8WatchRuleSignal> rules, DateTime now)
+    {
+        var (monitorState, reason) = ResolveMonitor(rules, now);
+        var business = seriousCount > 0 ? "CRITICAL"
+            : followCount > 0 ? "WARNING"
+            : monitorState == "HEALTHY" ? "NORMAL"
+            : "UNKNOWN";
+        return new S8ModuleLampDecision(business, monitorState, reason);
+    }
+
+    private static (string State, string Reason) ResolveMonitor(IReadOnlyList<S8WatchRuleSignal> rules, DateTime now)
+    {
+        if (rules.Count == 0)
+            return ("UNMONITORED", "NO_RULE");
+        var runnable = rules.Where(x => IsRunnable(x, now)).ToList();
+        if (runnable.Count == 0)
+            return ("PAUSED", rules.Any(x => x.Enabled) ? "RULE_PAUSED" : "RULE_DISABLED");
+
+        var reasons = runnable.Select(x => RuleReason(x, now)).Where(x => x != "OK").ToList();
+        if (reasons.Count == 0)
+            return ("HEALTHY", "OK");
+        if (reasons.Contains("RUN_FAILED"))
+            return ("DEGRADED", "RUN_FAILED");
+        if (reasons.Contains("NEVER_RUN"))
+            return ("DEGRADED", "NEVER_RUN");
+        return ("DEGRADED", "RUN_STALE");
+    }
+
+    private static string RuleReason(S8WatchRuleSignal rule, DateTime now)
+    {
+        if (!string.Equals(rule.LastStatus, "SUCCESS", StringComparison.OrdinalIgnoreCase))
+            return rule.LastRunAt.HasValue ? "RUN_FAILED" : "NEVER_RUN";
+        if (!rule.LastRunAt.HasValue)
+            return "NEVER_RUN";
+        var window = TimeSpan.FromSeconds(FreshWindowSeconds(rule.PollIntervalSeconds));
+        return now - rule.LastRunAt.Value <= window ? "OK" : "RUN_STALE";
+    }
+}
+
+public sealed record S8WatchRuleSignal(
+    string StageCode,
+    bool Enabled,
+    int PollIntervalSeconds,
+    string? LastStatus,
+    DateTime? LastRunAt,
+    DateTime? PausedUntil);
+
+public sealed record S8ModuleLampDecision(string BusinessState, string MonitorState, string MonitorReasonCode);
+
+public sealed record S8HomeAlertItem(string Time, string Module, string Message, string Level, string LevelLabel);
+
+public sealed record S8HomeAlertSummary(DateTime WindowStart, DateTime WindowEnd, bool IsToday, int Total, int Critical);
+
+public sealed record S8HomeTrendPoint(DateTime Date, int Count);
+
+public sealed record S8ModuleLampItem(
+    string Code,
+    string Name,
+    string BusinessState,
+    string MonitorState,
+    int SeriousCount,
+    int FollowCount,
+    int OpenCount,
+    int ConfiguredRuleCount,
+    int EnabledRuleCount,
+    int RunnableRuleCount,
+    DateTime? LatestRunAt,
+    string MonitorReasonCode);
+
+public sealed record S8ModuleLampPayload(DateTime AsOf, IReadOnlyList<S8ModuleLampItem> Items);
+
+internal sealed class S8HomeAlertRaw
+{
+    public string? Time { get; set; }
+    public string? Module { get; set; }
+    public string? Message { get; set; }
+    public string? Severity { get; set; }
+}
+
+internal sealed class S8HomeSummaryRaw
+{
+    public int Total { get; set; }
+    public int Critical { get; set; }
+}
+
+internal sealed class S8HomeTrendRaw
+{
+    public DateTime BizDate { get; set; }
+    public int Cnt { get; set; }
+}
+
+internal sealed class S8StageSeverityRaw
+{
+    public string? StageCode { get; set; }
+    public string? Severity { get; set; }
+}
+
+internal sealed class S8WatchRuleRaw
+{
+    public string? StageCode { get; set; }
+    public bool Enabled { get; set; }
+    public int PollIntervalSeconds { get; set; }
+    public string? LastStatus { get; set; }
+    public DateTime? LastRunAt { get; set; }
+    public DateTime? PausedUntil { get; set; }
+}

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/HomeGridDisplayPolicy.cs

@@ -0,0 +1,98 @@
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// 九宫格卡片同时保留最后可用值和最新一次计算。展示状态只看行主键,不看日期是否相同。
+/// </summary>
+public static class HomeGridDisplayPolicy
+{
+    public sealed class Row
+    {
+        public long Id { get; init; }
+        public decimal? MetricValue { get; init; }
+        public decimal? TargetValue { get; init; }
+        public string? ResultStatus { get; init; }
+        public string? ResultReason { get; init; }
+        public DateTime BizDate { get; init; }
+    }
+
+    public sealed class Card
+    {
+        public decimal? CurrentValue { get; init; }
+        public decimal? TargetValue { get; init; }
+        public string? ResultStatus { get; init; }
+        public string? ResultReason { get; init; }
+        public DateTime? BizDate { get; init; }
+        public string? LatestResultStatus { get; init; }
+        public string? LatestResultReason { get; init; }
+        public DateTime? LatestBizDate { get; init; }
+        public string DisplayState { get; init; } = "NO_DATA";
+        public string FreshnessState { get; init; } = "NO_VALUE";
+        public int? ValueAgeDays { get; init; }
+        public int? FreshnessSlaDays { get; init; }
+        public bool FutureBizDate { get; init; }
+    }
+
+    public static Card Decide(Row? usable, Row? latest, int? slaDays, DateTime today)
+    {
+        string displayState;
+        if (usable == null)
+        {
+            displayState = string.Equals(latest?.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)
+                ? "SOURCE_UNSUPPORTED"
+                : "NO_DATA";
+        }
+        else if (latest != null && usable.Id == latest.Id)
+        {
+            displayState = "CURRENT_VALUE";
+        }
+        else
+        {
+            displayState = "LAST_KNOWN_VALUE";
+        }
+
+        var freshness = "NO_VALUE";
+        int? age = null;
+        var future = false;
+        if (usable != null)
+        {
+            if (slaDays is null or <= 0)
+            {
+                freshness = "POLICY_MISSING";
+            }
+            else
+            {
+                var rawAge = (today.Date - usable.BizDate.Date).Days;
+                if (rawAge < 0)
+                {
+                    future = true;
+                    age = 0;
+                }
+                else
+                {
+                    age = rawAge;
+                }
+                freshness = age > slaDays ? "STALE" : "FRESH";
+            }
+        }
+
+        return new Card
+        {
+            CurrentValue = usable?.MetricValue,
+            TargetValue = usable?.TargetValue,
+            ResultStatus = usable == null ? latest?.ResultStatus : NormalizeUsableStatus(usable.ResultStatus),
+            ResultReason = usable?.ResultReason,
+            BizDate = usable?.BizDate,
+            LatestResultStatus = latest?.ResultStatus,
+            LatestResultReason = latest?.ResultReason,
+            LatestBizDate = latest?.BizDate,
+            DisplayState = displayState,
+            FreshnessState = freshness,
+            ValueAgeDays = age,
+            FreshnessSlaDays = slaDays,
+            FutureBizDate = future,
+        };
+    }
+
+    private static string NormalizeUsableStatus(string? status) =>
+        string.IsNullOrWhiteSpace(status) ? "OK" : status;
+}

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -61,7 +61,6 @@ public class Startup : AppStartup
         services.AddHostedService<Admin.NET.Plugin.AiDOP.Infrastructure.EtlInstanceRegistrar>();
         // MDP 出站事件驱动:入队 Pulse → 立即推送;Period Job 仅兜底
         services.AddHostedService<MdpOutboxPushWorker>();
-        services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpSourceConnectionImportService>();
         services.AddHostedService<MdpHotWatchWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.S1Refresh.S1DashboardRebuildWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildWorker>();