Forráskód Böngészése

fix(kanban): 补上 S5_L1_004 并让九宫格按卡片自己的业务日展示

S5 唯一算出真值的品类物料库存周转没有登记到本租户的字典和布局,九宫格看不到。
卡片值按指标各自取最新业务日,但页面只有一个模块级日期,会把 8 月的数标成 9 月。

- 种子与迁移 1.0.591 按「有值却无登记」补字典和布局,不写死租户
- home-grid 的每张卡返回自己的 bizDate;筛选旁路给不出日期时显式为 null
- 卡片标注「数据截至」,超过 7 天弱化。无业务数据不标日期
- 两处 gate log 的 GROUP_CONCAT 改为进聚合前限条,写入失败只记 warning
- 连接串组装时剔除 ConfigId 等非法键并告警;健康检查把非法键写进 health_msg
- 清掉 T8_V5_SQLSERVER 的 db_extra_params。未改 1.0.178.sql:已成功脚本一改 SHA256,启动会被拒绝
- 删除已无调用点、且含演示数字的 home-l1 前端链路,后端端点标为弃用

零回归:3217 个测试,7 个失败与既有基线同名。

chore: bump version Web 2.4.413 / server 1.0.591
Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 1 napja
szülő
commit
f613da8bc5
22 módosított fájl, 1169 hozzáadás és 540 törlés
  1. 1 1
      Web/package.json
  2. 0 19
      Web/src/views/aidop/api/kanbanData.ts
  3. 0 176
      Web/src/views/aidop/kanban/data/homeModulesSync.ts
  4. 13 1
      Web/src/views/dashboard/components/HomeCardGridView.vue
  5. 12 1
      Web/src/views/dashboard/components/HomeCardTableView.vue
  6. 12 0
      Web/src/views/dashboard/components/HomeCardTilesView.vue
  7. 27 0
      Web/src/views/dashboard/components/HomeModuleKpiCard.vue
  8. 0 180
      Web/src/views/dashboard/data/homeModulesSync.ts
  9. 0 109
      Web/src/views/dashboard/data/s2Kpis.ts
  10. 659 0
      doc/plan/九宫格S5-S9无数据根因修复执行任务书.md
  11. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  12. 84 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.sql
  13. 26 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.591.verify.sql
  14. 105 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/GridAndInboundGuardTests.cs
  15. 7 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs
  16. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.S4.cs
  17. 5 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  18. 83 5
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs
  19. 53 22
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/ShipTransNeutralProjection.cs
  20. 8 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs
  21. 13 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs
  22. 50 20
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

+ 1 - 1
Web/package.json

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

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

@@ -1,15 +1,6 @@
 import service from '/@/utils/request';
 import service from '/@/utils/request';
 import { resolveAidopTenantId, withAidopTenantParams } from './aidopTenant';
 import { resolveAidopTenantId, withAidopTenantParams } from './aidopTenant';
 
 
-export interface HomeL1Row {
-	moduleCode: string;
-	metricCode: string;
-	metricValue: number;
-	targetValue: number;
-	statusColor: string;
-	trendFlag: string;
-}
-
 export interface S8AlertRow {
 export interface S8AlertRow {
 	time: string;
 	time: string;
 	module: string;
 	module: string;
@@ -700,16 +691,6 @@ export async function closeImprovementPlan(id: number) {
 	return (res.data ?? null) as SmartOpsImprovementPlan | null;
 	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(
 export async function fetchS8Alerts(
 	factoryId = 1,
 	factoryId = 1,
 	extra: KanbanExtraQuery = {}
 	extra: KanbanExtraQuery = {}

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

+ 13 - 1
Web/src/views/dashboard/components/HomeCardGridView.vue

@@ -4,7 +4,7 @@
     class="s4-kpi-grid"
     class="s4-kpi-grid"
     :class="{ 's4-kpi-grid--dense': props.cells.length > 4 }"
     :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
       <el-tooltip
         v-if="cell.formulaText"
         v-if="cell.formulaText"
         placement="top"
         placement="top"
@@ -21,6 +21,7 @@
             <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
             <span v-if="cell.unit" class="s4-kpi-unit">{{ cell.unit }}</span>
           </div>
           </div>
           <div v-if="cell.targetDisplay" class="s4-kpi-target">目标: {{ cell.targetDisplay }}</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>
         </div>
         <span
         <span
           class="s4-kpi-trend"
           class="s4-kpi-trend"
@@ -156,6 +157,17 @@ const overflowCount = computed(() => {
   line-height: 1.3;
   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 {
 .s4-kpi-value {
   font-size: clamp(16px, 3vw, 22px);
   font-size: clamp(16px, 3vw, 22px);
   font-weight: 800;
   font-weight: 800;

+ 12 - 1
Web/src/views/dashboard/components/HomeCardTableView.vue

@@ -16,7 +16,7 @@
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody>
       <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">
           <td class="td-label" :title="cell.label">
             <el-tooltip
             <el-tooltip
               v-if="cell.formulaText"
               v-if="cell.formulaText"
@@ -27,6 +27,7 @@
               <span class="td-label__text td-label__text--formula">{{ cell.label }}</span>
               <span class="td-label__text td-label__text--formula">{{ cell.label }}</span>
             </el-tooltip>
             </el-tooltip>
             <span v-else class="td-label__text">{{ cell.label }}</span>
             <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>
           </td>
           </td>
           <td class="td-num">{{ cell.targetDisplay || '—' }}</td>
           <td class="td-num">{{ cell.targetDisplay || '—' }}</td>
           <td class="td-num">
           <td class="td-num">
@@ -177,6 +178,16 @@ defineProps({
   vertical-align: middle;
   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--good { color: #4ade80; }
 .td-gap--warn { color: #facc15; }
 .td-gap--warn { color: #facc15; }
 .td-gap--bad { color: #fb923c; }
 .td-gap--bad { color: #fb923c; }

+ 12 - 0
Web/src/views/dashboard/components/HomeCardTilesView.vue

@@ -12,6 +12,7 @@
         'tile--good': cell.trendTone === 'good',
         'tile--good': cell.trendTone === 'good',
         'tile--warn': cell.trendTone === 'warn',
         'tile--warn': cell.trendTone === 'warn',
         'tile--bad': cell.trendTone === 'bad',
         'tile--bad': cell.trendTone === 'bad',
+        'tile--stale': cell.stale,
       }"
       }"
     >
     >
       <span class="tile__accent" aria-hidden="true"></span>
       <span class="tile__accent" aria-hidden="true"></span>
@@ -32,6 +33,7 @@
           <span class="tile__value">{{ cell.value }}</span>
           <span class="tile__value">{{ cell.value }}</span>
           <span v-if="cell.unit" class="tile__unit">{{ cell.unit }}</span>
           <span v-if="cell.unit" class="tile__unit">{{ cell.unit }}</span>
         </div>
         </div>
+        <div v-if="cell.asOfText" class="tile__asof" :title="cell.staleHint || cell.asOfText">{{ cell.asOfText }}</div>
       </div>
       </div>
     </div>
     </div>
     <div v-if="overflowCount > 0" class="tiles-view__overflow" :title="`还有 ${overflowCount} 个指标未显示`">
     <div v-if="overflowCount > 0" class="tiles-view__overflow" :title="`还有 ${overflowCount} 个指标未显示`">
@@ -199,6 +201,16 @@ const overflowCount = computed(() => {
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
+.tile__asof {
+  font-size: 10px;
+  color: #94a3b8;
+  line-height: 1.3;
+}
+
+.tile--stale {
+  opacity: 0.55;
+}
+
 .tiles-view--dense .tile__body {
 .tiles-view--dense .tile__body {
   gap: 2px;
   gap: 2px;
   padding: 6px 8px 8px;
   padding: 6px 8px 8px;

+ 27 - 0
Web/src/views/dashboard/components/HomeModuleKpiCard.vue

@@ -78,12 +78,36 @@ function fmtTargetDisplay(v, unit) {
   return `${num}`
   return `${num}`
 }
 }
 
 
+const STALE_AFTER_DAYS = 7
+
+function asOfMeta(item) {
+  const status = String(item?.resultStatus || '')
+  if (status === 'NO_DATA' || status === 'SOURCE_UNSUPPORTED') {
+    return { asOfText: '', stale: false, staleHint: '' }
+  }
+  const raw = item?.bizDate
+  if (raw == null || raw === '') return { asOfText: '', stale: false, staleHint: '' }
+  const d = String(raw).slice(0, 10)
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return { asOfText: '', stale: false, staleHint: '' }
+  const biz = new Date(`${d}T00:00:00`)
+  const today = new Date()
+  today.setHours(0, 0, 0, 0)
+  const ageDays = Math.round((today.getTime() - biz.getTime()) / 86400000)
+  const stale = ageDays > STALE_AFTER_DAYS
+  return {
+    asOfText: `数据截至 ${d}`,
+    stale,
+    staleHint: stale ? `数据陈旧:业务日 ${d},已超过 ${STALE_AFTER_DAYS} 天` : '',
+  }
+}
+
 function mapItem(item) {
 function mapItem(item) {
   const arrow = item.gapArrow
   const arrow = item.gapArrow
   const trendArrow = arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat'
   const trendArrow = arrow === 'up' ? 'up' : arrow === 'down' ? 'down' : 'flat'
   const lv = String(item.achievementLevel || 'green')
   const lv = String(item.achievementLevel || 'green')
   const trendTone = lv === 'red' ? 'bad' : lv === 'yellow' ? 'warn' : 'good'
   const trendTone = lv === 'red' ? 'bad' : lv === 'yellow' ? 'warn' : 'good'
   const unit = item.unit ?? ''
   const unit = item.unit ?? ''
+  const asOf = asOfMeta(item)
   const formula =
   const formula =
     item.formulaText != null && String(item.formulaText).trim()
     item.formulaText != null && String(item.formulaText).trim()
       ? String(item.formulaText).trim()
       ? String(item.formulaText).trim()
@@ -100,6 +124,9 @@ function mapItem(item) {
     trend: item.gapLabel || '—',
     trend: item.gapLabel || '—',
     trendArrow,
     trendArrow,
     trendTone,
     trendTone,
+    asOfText: asOf.asOfText,
+    stale: asOf.stale,
+    staleHint: asOf.staleHint,
   }
   }
 }
 }
 
 

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

+ 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,需单独排查。

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨÓÃȨÏÞ¿ª·¢Æ½Ì¨</Description>
     <Description>Admin.NET ͨÓÃȨÏÞ¿ª·¢Æ½Ì¨</Description>
-    <AssemblyVersion>1.0.590</AssemblyVersion>
-    <FileVersion>1.0.590</FileVersion>
-    <Version>1.0.590</Version>
+    <AssemblyVersion>1.0.591</AssemblyVersion>
+    <FileVersion>1.0.591</FileVersion>
+    <Version>1.0.591</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -1012,6 +1012,12 @@
     <None Update="UpdateScripts\1.0.589.verify.sql">
     <None Update="UpdateScripts\1.0.589.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </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\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </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;

+ 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("v.biz_date AS BizDate", source);
+        Assert.Contains("filteredBundle == null ? v?.BizDate : null", 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 = itemBizDate", 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("找不到仓库根目录");
+    }
+}

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Generic.cs

@@ -94,7 +94,8 @@ public partial class AidopKanbanController
 
 
         const string valSql = """
         const string valSql = """
 SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
 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
+       v.result_status AS ResultStatus, v.result_reason AS ResultReason,
+       v.biz_date AS BizDate
 FROM ado_s9_kpi_value_l1_day v
 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
 WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
   AND v.biz_date=(
   AND v.biz_date=(
@@ -141,6 +142,9 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
             var gap = AidopS4KpiMerge.GapValue(cur, tgt);
             var gap = AidopS4KpiMerge.GapValue(cur, tgt);
             var arrow = AidopS4KpiMerge.GapArrow(gap);
             var arrow = AidopS4KpiMerge.GapArrow(gap);
             var label = FormatGapLabelGeneric(gap, k?.Unit);
             var label = FormatGapLabelGeneric(gap, k?.Unit);
+            // 筛选旁路(filteredBundle)目前给不出业务日。显式 null,禁止回落到模块级 bizDate,
+            // 否则一张 8 月的卡会被标成模块里最新的那一天。
+            DateTime? itemBizDate = filteredBundle == null ? v?.BizDate : null;
             items.Add(new
             items.Add(new
             {
             {
                 rowId = row.RowId,
                 rowId = row.RowId,
@@ -151,6 +155,7 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
                 targetValue = tgt,
                 targetValue = tgt,
                 resultStatus = v?.ResultStatus,
                 resultStatus = v?.ResultStatus,
                 resultReason = v?.ResultReason,
                 resultReason = v?.ResultReason,
+                bizDate = itemBizDate?.ToString("yyyy-MM-dd"),
                 gapValue = gap,
                 gapValue = gap,
                 gapLabel = label,
                 gapLabel = label,
                 gapArrow = arrow,
                 gapArrow = arrow,
@@ -163,6 +168,7 @@ WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
         {
         {
             moduleCode = mc,
             moduleCode = mc,
             layoutPattern = hm?.LayoutPattern ?? "card_grid",
             layoutPattern = hm?.LayoutPattern ?? "card_grid",
+            // 模块内最新的一天,不是每张卡的业务日。卡片日期看 items[].bizDate。
             bizDate = bizDate.ToString("yyyy-MM-dd"),
             bizDate = bizDate.ToString("yyyy-MM-dd"),
             items
             items
         });
         });

+ 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 decimal? TargetValue { get; set; }
         public string? ResultStatus { get; set; }
         public string? ResultStatus { get; set; }
         public string? ResultReason { get; set; }
         public string? ResultReason { get; set; }
+        /// <summary>该指标自己的业务日。S4 首页 SQL 不选此列,保持 null,不影响 S4。</summary>
+        public DateTime? BizDate { get; set; }
     }
     }
 }
 }

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

@@ -87,6 +87,11 @@ public partial class AidopKanbanController : ControllerBase
         _s8KpiSnapshot = s8KpiSnapshot;
         _s8KpiSnapshot = s8KpiSnapshot;
     }
     }
 
 
+    /// <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")]
     [HttpGet("home-l1")]
     public async Task<IActionResult> GetHomeL1([FromQuery] long factoryId = 1)
     public async Task<IActionResult> GetHomeL1([FromQuery] long factoryId = 1)
     {
     {

+ 83 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs

@@ -1,5 +1,7 @@
+using System.Data.Common;
 using Admin.NET.Core;
 using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 using SqlSugar;
 
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform;
 namespace Admin.NET.Plugin.AiDOP.DataPlatform;
@@ -12,10 +14,12 @@ public sealed class MdpSourceScopeFactory : ITransient
 {
 {
     private const string ConfigIdPrefix = "mdp-src-";
     private const string ConfigIdPrefix = "mdp-src-";
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
+    private readonly ILogger<MdpSourceScopeFactory>? _logger;
 
 
-    public MdpSourceScopeFactory(ISqlSugarClient db)
+    public MdpSourceScopeFactory(ISqlSugarClient db, ILogger<MdpSourceScopeFactory>? logger = null)
     {
     {
         _db = db;
         _db = db;
+        _logger = logger;
     }
     }
 
 
     /// <summary>按 sourceCode 获取连接域;优先复用已注册 ConfigId。</summary>
     /// <summary>按 sourceCode 获取连接域;优先复用已注册 ConfigId。</summary>
@@ -114,7 +118,7 @@ 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))
         if (string.IsNullOrWhiteSpace(source.DbHost) || string.IsNullOrWhiteSpace(source.DbName))
             throw new InvalidOperationException($"源 {source.SourceCode} 缺少 db_host/db_name");
             throw new InvalidOperationException($"源 {source.SourceCode} 缺少 db_host/db_name");
@@ -122,9 +126,15 @@ public sealed class MdpSourceScopeFactory : ITransient
         var password = DecryptPassword(source.DbPasswordEnc);
         var password = DecryptPassword(source.DbPasswordEnc);
         var port = source.DbPort;
         var port = source.DbPort;
         var user = source.DbUser ?? "";
         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 =>
             DbType.SqlServer =>
                 $"Server={source.DbHost}{(port is > 0 ? $",{port}" : "")};Database={source.DbName};User Id={user};Password={password};TrustServerCertificate=true;Encrypt=false{extra}",
                 $"Server={source.DbHost}{(port is > 0 ? $",{port}" : "")};Database={source.DbName};User Id={user};Password={password};TrustServerCertificate=true;Encrypt=false{extra}",
@@ -138,6 +148,74 @@ public sealed class MdpSourceScopeFactory : ITransient
         };
         };
     }
     }
 
 
+    /// <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)
+    {
+        dropped = new List<string>();
+        if (string.IsNullOrWhiteSpace(extra)) return null;
+
+        DbConnectionStringBuilder parsed;
+        try
+        {
+            parsed = new DbConnectionStringBuilder { ConnectionString = extra.Trim().TrimStart(';') };
+        }
+        catch (ArgumentException)
+        {
+            dropped.Add("(unparseable)");
+            return null;
+        }
+
+        var reserved = ReservedConnectionKeys(dbType);
+        var kept = new DbConnectionStringBuilder();
+        foreach (string key in parsed.Keys)
+        {
+            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)
+    };
+
     private static string DecryptPassword(string? enc)
     private static string DecryptPassword(string? enc)
     {
     {
         if (string.IsNullOrEmpty(enc)) return "";
         if (string.IsNullOrEmpty(enc)) return "";

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

@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 using SqlSugar;
 
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
@@ -33,11 +34,13 @@ public sealed class ShipTransNeutralProjection : ITransient
 
 
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly MdpNeutralSourceGate _gate;
     private readonly MdpNeutralSourceGate _gate;
+    private readonly ILogger _logger;
 
 
-    public ShipTransNeutralProjection(ISqlSugarClient db, MdpNeutralSourceGate gate)
+    public ShipTransNeutralProjection(ISqlSugarClient db, MdpNeutralSourceGate gate, ILoggerFactory loggerFactory)
     {
     {
         _db = db;
         _db = db;
         _gate = gate;
         _gate = gate;
+        _logger = loggerFactory.CreateLogger(nameof(ShipTransNeutralProjection));
     }
     }
 
 
     public async Task<int> ProjectAsync(long tenantId, string? batchId, CancellationToken cancellationToken = default)
     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("@tid", tenantId),
             new SugarParameter("@batch", batchId ?? ""));
             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(
         await _db.Ado.ExecuteCommandAsync(
             $"""
             $"""

+ 8 - 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个/人。",
             "1、每月统计当月物料仓储人效。\n2、例如:3月上线的工单物料共10000个/仓储人数4人,则该物料仓储人效=10000/4=2500个/人。",
             "S5-物料仓储", "天", "仓储物流部",
             "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,
         var l1_s6_1 = Ins(db, tenantId, "S6_L1_001", "S6", 1, null, "订单制造周期(天)", "天", "lower_is_better", 20, true,
             "从订单投产到订单完工的时长",
             "从订单投产到订单完工的时长",
             "每个订单的制造周期=D2-D1",
             "每个订单的制造周期=D2-D1",

+ 13 - 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))
                 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);
                     var scopeDb = await scopeFactory.GetScopeAsync(src.SourceCode, stoppingToken);
                     await scopeDb.Ado.GetIntAsync("SELECT 1");
                     await scopeDb.Ado.GetIntAsync("SELECT 1");
                     src.HealthStatus = 1;
                     src.HealthStatus = 1;
-                    src.HealthMsg = "OK";
+                    src.HealthMsg = illegalNote == null ? "OK" : $"OK({illegalNote})";
                     ok++;
                     ok++;
                 }
                 }
                 else if (string.Equals(src.SourceType, "API", StringComparison.OrdinalIgnoreCase))
                 else if (string.Equals(src.SourceType, "API", StringComparison.OrdinalIgnoreCase))
@@ -75,7 +79,14 @@ public class MdpSourceHealthCheckJob : IJob
             catch (Exception ex)
             catch (Exception ex)
             {
             {
                 src.HealthStatus = 0;
                 src.HealthStatus = 0;
-                src.HealthMsg = ex.Message.Length > 480 ? ex.Message[..480] : ex.Message;
+                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)},已忽略。";
+                var msg = prefix + ex.Message;
+                src.HealthMsg = msg.Length > 480 ? msg[..480] : msg;
                 fail++;
                 fail++;
             }
             }
 
 

+ 50 - 20
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

@@ -888,26 +888,56 @@ public sealed class InventoryMdpSyncService : ITransient
             return;
             return;
         if (!await _neutralGate.AllowsAsync(targetTenantId, "WO_LINE_PROD", MdpSourceIdentity.Native, syncBatchId: batchId))
         if (!await _neutralGate.AllowsAsync(targetTenantId, "WO_LINE_PROD", MdpSourceIdentity.Native, syncBatchId: batchId))
             return;
             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>
     /// <summary>