Эх сурвалжийг харах

fix(s5): 指定租户库存物化等待同一把锁并重试死锁

半小时贴源同步与 S5/S7 重算会同时写标准流水。重算入口改为最多等待 10 分钟,物化语句只对 MySQL 1213/1205 重试三次。server 1.0.592。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 16 цаг өмнө
parent
commit
42c9fa2dd7

+ 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 哈希和六面反向影响推演。
+```
+
+该启动指令不授权执行模型跳过仓库的“改动前确认”规则;它只固定执行方法与停机条件。

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨÓÃȨÏÞ¿ª·¢Æ½Ì¨</Description>
-    <AssemblyVersion>1.0.591</AssemblyVersion>
-    <FileVersion>1.0.591</FileVersion>
-    <Version>1.0.591</Version>
+    <AssemblyVersion>1.0.592</AssemblyVersion>
+    <FileVersion>1.0.592</FileVersion>
+    <Version>1.0.592</Version>
   </PropertyGroup>
 
   <ItemGroup>

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

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

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