Преглед изворни кода

feat(mdp): 落地第三方标准 API 推数入站(方式丙)

同步方案、指南、详表与 P-046 待办。chore: bump version server 1.0.538

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu пре 1 месец
родитељ
комит
b8d7b76e9b
53 измењених фајлова са 6612 додато и 125 уклоњено
  1. 10 1
      doc/README.md
  2. 12 0
      doc/_formalize_delivery_docs.py
  3. 10 0
      doc/_unify_ab_delivery_vnext.py
  4. 8 8
      doc/_unify_doc_format.py
  5. 5 5
      doc/plan/AIDOP双模式全模块对接交付级任务书.md
  6. 391 0
      doc/plan/AIDOP标准与非标双模BOM升级方案.md
  7. 1 1
      doc/plan/Ai-DOP剩余待办统一执行任务书_20260814.md
  8. 4 1
      doc/plan/Ai-DOP项目待办清单.md
  9. 257 0
      doc/plan/ChatBI改善方案.md
  10. 177 0
      doc/plan/S0未走数据中台的问题.md
  11. 1 1
      doc/plan/UAT测试/README.md
  12. 128 15
      doc/plan/UAT测试/内部UAT测试待办项.md
  13. 2 0
      doc/plan/aidopdev_165库容量与增量写入分析_20260716.md
  14. 164 0
      doc/plan/当前系统针对标准和非标BOM的适用情况分析.md
  15. 176 0
      doc/plan/第三方标准API推数入站-阶段0字段对表草稿.md
  16. 675 0
      doc/plan/第三方标准API推数入站执行任务书.md
  17. 588 0
      doc/plan/第三方标准API推数入站方案.md
  18. BIN
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.docx
  19. 40 6
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md
  20. BIN
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.docx
  21. 35 28
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md
  22. BIN
      doc/plan/系统集成/Ai-DOP第三方系统集成指南.docx
  23. 257 51
      doc/plan/系统集成/Ai-DOP第三方系统集成指南.md
  24. 8 5
      doc/plan/系统集成/README.md
  25. 74 0
      doc/plan/针对第三方标准API推数入站方案的评审.md
  26. 21 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  27. 194 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.536.sql
  28. 32 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.536.verify.sql
  29. 25 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.537.sql
  30. 16 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.537.verify.sql
  31. 146 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.538.sql
  32. 39 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.538.verify.sql
  33. 151 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs
  34. 19 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureDefaults.cs
  35. 143 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureHandler.cs
  36. 8 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureOptions.cs
  37. 332 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdmMirrorUpsertService.cs
  38. 69 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundAuthService.cs
  39. 199 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs
  40. 107 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundModels.cs
  41. 119 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundModuleTrigger.cs
  42. 955 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundReceiveService.cs
  43. 298 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundSnapshotService.cs
  44. 16 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpEntity.cs
  45. 274 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpInboundEntities.cs
  46. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Sales/AdoS0CustMaster.cs
  47. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Sales/AdoS0ItemMaster.cs
  48. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Supply/AdoS0SuppMaster.cs
  49. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0EmployeeMaster.cs
  50. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0LocationMaster.cs
  51. 127 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs
  52. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs
  53. 262 0
      tools/mock/inbound/push_demo.py

+ 10 - 1
doc/README.md

@@ -67,6 +67,10 @@
 | [plan/S4/S4采购执行UAT测试数据生成方案.md](./plan/S4/S4采购执行UAT测试数据生成方案.md) | S4 采购执行 UAT 测试数据包,当前以测试报告驱动,覆盖交货、发货、退货、IQC 与欠料看板 |
 | [plan/S4/S4采购执行UAT数据生成执行任务书.md](./plan/S4/S4采购执行UAT数据生成执行任务书.md) | 给其它大模型或协作者执行 S4 UAT 数据生成的阶段任务、约束、确认点与验收标准 |
 | [plan/S0-CodeFirst启动异常与BOM历史数据治理方案.md](./plan/S0-CodeFirst启动异常与BOM历史数据治理方案.md) | S0 legacy BOM 表 CodeFirst 启动异常、根因与彻底治理步骤 |
+| [plan/当前系统针对标准和非标BOM的适用情况分析.md](./plan/当前系统针对标准和非标BOM的适用情况分析.md) | **待讨论**:标准 / MTO / 轻非标 / 真 ETO 对现有三轨 BOM 的适配结论;内部 UAT 见 `UAT-INT-005` |
+| [plan/S0未走数据中台的问题.md](./plan/S0未走数据中台的问题.md) | **待讨论**:S0 与中台双向断(写出不进中台、入站不回灌、BOM 双轨);内部 UAT 见 `UAT-INT-006` |
+| [plan/AIDOP标准与非标双模BOM升级方案.md](./plan/AIDOP标准与非标双模BOM升级方案.md) | **目标方案(最佳完整路径)**:统一修订权威、标准/非标双模、三轨迁入摘除、中台闭环与 165 出站反向 |
+| [plan/第三方标准API推数入站方案.md](./plan/第三方标准API推数入站方案.md) | **方式丙终态**:`POST /api/v1/mdp/inbound/{entityCode}`;五层(身份/契约/摄取/规范/发布);UAT-INT-004 |
 | [plan/S8/S8数据库处理优化方案.md](./plan/S8/S8数据库处理优化方案.md) | S8 调度、检测日志、通知扫描与数据库写入压力优化方案 |
 | [plan/aidopdev_165库容量与增量写入分析_20260716.md](./plan/aidopdev_165库容量与增量写入分析_20260716.md) | 165/`aidopdev` 库容量快照、Top 表、调度增量写入与治理建议(2026-07-16) |
 | [plan/新系统数据库简明介绍与S1数据流.md](./plan/新系统数据库简明介绍与S1数据流.md) | 新系统数据库简明说明,并以 S1 为例说明数据中台流转 |
@@ -89,6 +93,7 @@
 | [plan/S2优先级调整误写交期与计划作废修复执行任务书.md](./plan/S2优先级调整误写交期与计划作废修复执行任务书.md) | **可派发任务书**:P-028 §6.5 真人验收派生的两个 P0。① 「工单优先级调整」弹窗把只读的「订单日期」当交期提交,后端解析成 `DueDate` 落库 → **打开弹窗按一次保存就把交期无声改成订单日期**,并连带触发单工单重排与 `crm_seorderentry` 同步(实证:交期 2026-09-05 → 2026-08-10);② 纯改优先级走「只作废不重排」分支,本库置 `IsActive=0` 并向 165 入队作废,两库同时失去日计划、MES 当天取不到计划,恢复须全量重排(实证:生效行 3 → 0,全租户 41 → 38)。给出 A1/B1 方案取舍(预览补返 `DueDate` + 弹窗字段归位;纯改优先级改为不作废不重排、以 `pendingReorder` warning 提示)、J1–J4 改动清单(**不改** `DeactivateExistingScheduleAsync` 本体,无 DDL)、V1–V6 验收与 A1–A5 可复制 SQL、存量污染排查口径(`DueDate=OrdDate` + 日志 `detail_json.before.DueDate` 可还原)、「先恢复数据再改代码」的顺序约束 |
 | [plan/旧系统MongoDB替代-待办清单.md](./plan/旧系统MongoDB替代-待办清单.md) | Mongo 替代专题待办明细(**T-MONGO**;入口见项目待办清单) |
 | [plan/动态运营详情看板与指标改善闭环方案.md](./plan/动态运营详情看板与指标改善闭环方案.md) | 动态运营详情看板、智慧诊断与改善闭环总体方案 |
+| [plan/ChatBI改善方案.md](./plan/ChatBI改善方案.md) | **ChatBI(FUNC-S9-007)改善方案**:与看板/智慧诊断/改善计划分工、操作指引(`how_to`)、硬边界、P0–P2 分期与验收口径(2026-09-04) |
 | [plan/看板查询条件现状分析.md](./plan/看板查询条件现状分析.md) | 看板查询条件为何不影响 KPI、前后端数据流与改造方向 |
 | [plan/数据中台模块扩展指南-S1至S3经验版.md](./plan/数据中台模块扩展指南-S1至S3经验版.md) | 基于 S1-S3 经验的 S4-S8 数据中台、KPI 报表与看板扩展指南,含 Cursor 小步闭环任务模板 |
 | [plan/数据库迁移/S1/S1-任务交接记忆.md](./plan/数据库迁移/S1/S1-任务交接记忆.md) | **S1 数据中台迁移**跨会话交接(当前进度、阻塞、验收 SQL) |
@@ -115,5 +120,9 @@
 
 ## 变更记录
 
-- **2026-09-04**:第三方对接指南与业务/技术详表(含 Word)归入 `plan/系统集成/`。
+- **2026-09-06**:重写 [plan/第三方标准API推数入站方案.md](./plan/第三方标准API推数入站方案.md) 为五层终态(否定只贴源/另案回灌);UAT-INT-004 待确认立项。
+- **2026-09-06**:新增 [plan/AIDOP标准与非标双模BOM升级方案.md](./plan/AIDOP标准与非标双模BOM升级方案.md)(标准/非标双模 + 三轨收口,非最小改动)。
+- **2026-09-06**:新增 [plan/S0未走数据中台的问题.md](./plan/S0未走数据中台的问题.md);内部 UAT 待办增补 `UAT-INT-006`,并在 `UAT-INT-003` / `UAT-INT-005` 加交叉提示。
+- **2026-09-06**:新增 [plan/当前系统针对标准和非标BOM的适用情况分析.md](./plan/当前系统针对标准和非标BOM的适用情况分析.md);内部 UAT 待办增补 `UAT-INT-005`。
+- **2026-09-04**:新增 [plan/ChatBI改善方案.md](./plan/ChatBI改善方案.md);第三方对接指南与业务/技术详表(含 Word)归入 `plan/系统集成/`。
 - **2026-04-16**:根目录 `docs/` 已合并至 `doc/`(含 `doc/db/` 与根下方案文档),并增加本规范文件。

+ 12 - 0
doc/_formalize_delivery_docs.py

@@ -50,6 +50,12 @@ DROP_RE = re.compile(
     r"MVP 不得伪称|列为后续|缺口(|本轮不单独立册"
 )
 
+# 现行正式稿已手改、禁止再从上一号重出覆盖。
+# S3 用户操作手册 V1.2:Release 手换「工单物料齐套上线看板」截图并调整图高。
+SKIP_JOBS = {
+    ("S3", "S3供应协同模块用户操作手册-V1.1.docx"),
+}
+
 JOBS = [
     ("S0", "S0-运营建模-业务需求描述-V0.7.docx", "V0.7", "V0.8"),
     ("S0", "S0-运营建模-业务蓝图设计方案-V0.7.docx", "V0.7", "V0.8"),
@@ -186,6 +192,12 @@ def write_generated() -> list[str]:
 def main() -> None:
     lines = []
     for module, src_name, old_ver, new_ver in JOBS:
+        if (module, src_name) in SKIP_JOBS:
+            dest = dest_name(src_name, new_ver)
+            msg = f"SKIP {module} {src_name} -> {dest} (hand-maintained)"
+            print(msg, flush=True)
+            lines.append(msg)
+            continue
         print(f"formalize {module} {src_name}", flush=True)
         try:
             msg = process_sxx(module, src_name, old_ver, new_ver)

+ 10 - 0
doc/_unify_ab_delivery_vnext.py

@@ -187,6 +187,11 @@ SCOPE = {
     ),
 }
 
+# 现行正式稿已手改:S3 手册截图以 V1.2 为准,勿再从无版本号源重出 V1.1。
+SKIP_JOBS = {
+    ("S3", "um", "S3供应协同模块用户操作手册.docx"),
+}
+
 JOBS = [
     ("S0", "brd", "S0-运营建模-业务需求描述-V0.6.docx", "V0.6", "V0.7"),
     ("S0", "bbp", "S0-运营建模-业务蓝图设计方案-V0.6.docx", "V0.6", "V0.7"),
@@ -738,6 +743,11 @@ def main() -> None:
     for module, kind, src_name, old_ver, new_ver in JOBS:
         if only and module not in only:
             continue
+        if (module, kind, src_name) in SKIP_JOBS:
+            msg = f"SKIP {module} {kind} {src_name} (hand-maintained)"
+            print(msg, flush=True)
+            lines.append(msg)
+            continue
         print(f"processing {module} {kind} {src_name} -> {new_ver}", flush=True)
         try:
             msg = process_job(module, kind, src_name, old_ver, new_ver)

+ 8 - 8
doc/_unify_doc_format.py

@@ -42,7 +42,7 @@ DOC_META = {
         "title1": "S1 产销协同模块",
         "title2": "业务需求描述",
         "doc_no": "S1-BRD-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-09",
         "updated": "2026-06-09",
     },
@@ -50,7 +50,7 @@ DOC_META = {
         "title1": "S1 产销协同模块",
         "title2": "业务蓝图设计方案",
         "doc_no": "S1-BBP-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-09",
         "updated": "2026-06-09",
     },
@@ -58,7 +58,7 @@ DOC_META = {
         "title1": "S2 制造协同模块",
         "title2": "业务需求描述",
         "doc_no": "S2-BRD-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-09",
         "updated": "2026-06-09",
     },
@@ -66,7 +66,7 @@ DOC_META = {
         "title1": "S2 制造协同模块",
         "title2": "业务蓝图设计方案",
         "doc_no": "S2-BBP-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-09",
         "updated": "2026-06-09",
     },
@@ -74,7 +74,7 @@ DOC_META = {
         "title1": "S3 供应协同模块",
         "title2": "业务需求描述",
         "doc_no": "S3-BRD-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-10",
         "updated": "2026-06-11",
     },
@@ -82,7 +82,7 @@ DOC_META = {
         "title1": "S3 供应协同模块",
         "title2": "业务蓝图设计方案",
         "doc_no": "S3-BBP-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-10",
         "updated": "2026-06-10",
     },
@@ -90,7 +90,7 @@ DOC_META = {
         "title1": "S4 采购执行模块",
         "title2": "业务需求描述",
         "doc_no": "S4-BRD-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-11",
         "updated": "2026-06-11",
     },
@@ -98,7 +98,7 @@ DOC_META = {
         "title1": "S4 采购执行模块",
         "title2": "业务蓝图设计方案",
         "doc_no": "S4-BBP-001",
-        "author": "彭熙玉",
+        "author": "智造易项目组",
         "created": "2026-06-11",
         "updated": "2026-06-11",
     },

+ 5 - 5
doc/plan/AIDOP双模式全模块对接交付级任务书.md

@@ -131,11 +131,11 @@
 - **验收**:§附录 E 契约一致 SQL 通过。
 - **需确认**:否(仅配置 + 复用)。
 
-### 任务 P-2:API_PUSH 入站 Webhook 接收端(可选,取决 D2 是否有源推 DOP)
-- **目标**:提供统一入站 Webhook:外部 POST → 幂等落 `mdp_stg_external_row`/指定 stg。
-- **改动文件**:新增 `Controllers/MdpInboundWebhookController.cs`(`[Route("api/mdp/inbound")]`,按 `entity_code` + 幂等键落库)。
-- **完成标准**:同一幂等键重复 POST 不重复入库;落 `mdp_sync_log`。
-- **需确认**:是(新增对外端点,需鉴权/白名单/限流方案)。
+### 任务 P-2:API_INBOUND 入站接收端(对方 POST 我方;原称 API_PUSH Webhook)
+- **状态**:**已落地(API_INBOUND)**(编码 A–F,server `1.0.538`;契约仍为 **v1-draft**,阶段 G 对外指南另立项)。
+- **目标**:见 [`第三方标准API推数入站方案.md`](./第三方标准API推数入站方案.md) **终态五层**(身份/契约/摄取/规范/发布)。路径 **`/api/mdp/inbound/{entityCode}`**(方案 v2 §4.1:首版隐含 v1,**不带** `/v1/`);贴源仍只走 `MdpStagingWriter`。执行细节见 [`第三方标准API推数入站执行任务书.md`](./第三方标准API推数入站执行任务书.md)。
+- **完成标准**:见方案 §11;发布门禁 G1(字段会签 + T8 口径书面确认)仍挂阶段 G 前。
+- **需确认**:阶段 G/H 另立项;不修 `api/aidop/out`(P-023)。
 
 ### 任务 P-3:源健康检查 Job(交付可观测性)
 - **目标**:定时探活各 `mdp_source`(DB `SELECT 1` / API health),写 `health_status/last_health_check/health_msg`。

+ 391 - 0
doc/plan/AIDOP标准与非标双模BOM升级方案.md

@@ -0,0 +1,391 @@
+# Ai-DOP 标准与非标双模 BOM 升级方案
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **目标架构与实施总方案**(最佳完整路径)。不是过渡补丁、不是最小改动清单 |
+| 编写日期 | 2026-09-06 |
+| 状态 | 待负责人确认后另开任务书编码;**本文不授权直接改代码** |
+| 依据 | 当前实现 + [`当前系统针对标准和非标BOM的适用情况分析.md`](./当前系统针对标准和非标BOM的适用情况分析.md) + [`S0未走数据中台的问题.md`](./S0未走数据中台的问题.md) |
+| 内部 UAT | `UAT-INT-005`(行业适配)、`UAT-INT-006`(S0/中台闭环)、`UAT-INT-003`(入站未回灌) |
+| 收口旧债 | **M-04**(`ic_bom` / `ProductStructureMaster` 双轨)并入本方案,不再单独用「再加一层镜像」打发 |
+
+---
+
+## 0. 一句话
+
+用**一份已发布结构**作为制造唯一真源;标准和非标只是**属主与生命周期不同**。三轨(S0 `ProductStructureMaster`、`ic_bom`、设计单 `ado_product_design_bom`)全部降为编辑面或一次性迁入源,禁止再当活真源。
+
+不考虑:只恢复 `SyncBom`、设计单回写 `ic_bom`、非标另做一套计算器、每张定制新建物料编码硬撑。
+
+---
+
+## 1. 目标与非目标
+
+### 1.1 目标
+
+1. **标准**:目录可多版本,按交期/生效窗解析;改目录出新版本,在制订单默认仍绑旧版。  
+2. **真非标**:一单一树;设计中改草稿;发布后订单行钉死该快照;未发布 = 待设计,禁止按物料猜最新。  
+3. **轻非标**:仍用设计单跟踪图号与周期;制造若未要求本单树,可只绑目录版本。  
+4. **三轨消失**:评审、齐套、工单、领料计划量,只读同一套已发布结构。  
+5. **中台闭环**:S0/发布写入业务权威表;中台从权威抽取;第三方入站回灌权威(或只读同一张权威表)。  
+6. **投产可追溯**:下达/发布时冻整棵树,不只冻 `FrozenBOMQty`。  
+7. **变更可管控**:变更单列出受影响工单、占用、采购,默认不污染其他订单。
+
+### 1.2 非目标(本方案明确不做的捷径)
+
+- 不保留「S0 改一行、指望某天作业灌进 `ic_bom`」作为终态。  
+- 不为非标新建第二套展开/损耗/虚拟件规则。  
+- 不把 `mdp_std_bom` 做成 ETL 才能写的「第四轨」,制造仍直查 `ic_bom`。  
+- 不在 165 上改表、改过程(C1 仍有效);MES 所需结构由本库**出站同步**。  
+- 不把周期规则、KPI、检验规范纳入本次 BOM 对象。  
+- 超级 BOM / 选配器(CTO 选项矩阵)可作为**同模型上的后续增量**,本方案数据模型预留,不阻塞首期双模。
+
+### 1.3 成功标准(产品语言)
+
+| 角色 | 怎样算「很适用」 |
+|------|------------------|
+| 标准计划员 | 改目录出新版本,旧单承诺与在制领料不变;新单按交期吃到正确版本 |
+| 非标设计 | 在设计单上改树,未发布不影响齐套;发布后评审/工单就是这一棵 |
+| 评审 / PMC | 缺的是「未绑定已发布结构」,不是「目录里没有这个编码」 |
+| 车间 / 165 | 领料数量来自冻结的整树,不是事后被目录改写 |
+| 实施 / 中台 | 第三方物料+BOM 进权威后,S0 页和评审都能用,看板同一口径 |
+
+---
+
+## 2. 选定架构(最佳,唯一主路径)
+
+**名称:统一修订(Revision)+ 属主 + 订单绑定。**
+
+```text
+                    编辑面(可多入口)
+         ┌────────────┼────────────────┐
+         ▼            ▼                ▼
+    S0 目录维护   S1 产品设计      第三方/PLM 入站
+         │            │                │
+         │  草稿      │  草稿           │  入站落草稿或按策略直接发布
+         └────────────┴────────────────┘
+                         │
+                    发布服务(业务动作,不是 ETL)
+                         │
+                         ▼
+              ┌─────────────────────┐
+              │  ado_bom_revision   │  制造唯一权威
+              │  ado_bom_component  │  DRAFT / RELEASED / SUPERSEDED
+              └──────────┬──────────┘
+                         │
+         订单行绑定 revision_id + bom_mode
+                         │
+          ┌──────────────┼──────────────┐
+          ▼              ▼              ▼
+        评审展开       齐套/占用       工单冻结整树
+          │
+          ▼
+     中台抽取 stg → std → dwd/KPI
+          │
+          ▼
+     出站 165 ProductStructureMaster / 工单明细(契约内)
+```
+
+### 2.1 为什么这是最佳而不是「折中」
+
+| 备选 | 为何不用 |
+|------|----------|
+| 只恢复旧 `SyncBom`(165 → `ic_bom`) | 标准维护变顺,非标仍一物一最新;三轨里的设计单还在 |
+| 设计单回写 `ic_bom` | 非标污染目录;无版本;中台仍两张表 |
+| 非标独立表 + 独立计算器 | 损耗/虚拟件/齐套排除永久分叉 |
+| 每单新建物料编码 | 主数据爆炸,后单改编码即污染 |
+| 制造改读 `mdp_std_bom`、权威仍散落 | ETL 成为发布通道,违反「ETL 无业务副作用」 |
+| 标准继续 `ic_bom`、非标用订单快照 | 双引擎变体,M-04 永远不关 |
+
+选定方案用**同一张修订表**表达目录版本和订单快照,用 `owner_type` 区分,展开器只认 `revision_id`。
+
+---
+
+## 3. 目标数据模型
+
+逻辑分层:业务权威在 `ado_bom_*`;`ic_bom` / `ProductStructureMaster` / 设计单子表在切流后不再被制造读取。表名实施时可微调,语义不得拆开。
+
+### 3.1 `ado_bom_revision`(修订头)
+
+| 字段(语义) | 说明 |
+|--------------|------|
+| `id` | 主键,展开与绑定只认这个 |
+| `tenant_id` / `factory_id` | 隔离 |
+| `owner_type` | `CATALOG` / `ORDER_LINE` / `DESIGN` |
+| `owner_key` | 目录=物料号;订单行=订单行 Id;设计=`ProductDesign.Id` |
+| `item_num` | 成品/父项物料(订单模式也要落,便于展示) |
+| `revision_code` | 对人显示的版本号,如 `A`、`B`、`20260906-1` |
+| `status` | `DRAFT` / `RELEASED` / `SUPERSEDED` |
+| `effective_from` / `effective_to` | **仅 CATALOG 生效窗**;订单修订不靠日期猜,靠绑定 |
+| `source_revision_id` | 从哪份复制/发布而来(抄近单、设计发布) |
+| `released_at` / `released_by` | 发布审计 |
+| 血缘 | `source_system`、`source_biz_key`(入站用) |
+
+约束:
+
+- 同一 `owner_type + owner_key + revision_code` 唯一。  
+- `RELEASED` 的组件行**不可就地改**;改结构只能新开 `DRAFT` 再发布(旧 `RELEASED` → `SUPERSEDED` 或仍被旧订单引用而保持 RELEASED)。  
+- 目录:同一物料同一时刻窗口内只允许一个 `RELEASED` 命中(窗口可重叠时发布服务拒绝或要求变更单裁夺)。
+
+### 3.2 `ado_bom_component`(组件行)
+
+沿用并收口现有三套字段的并集,而不是最瘦字段:
+
+- 父子(`parent_line_id` 可空=根下第一层;支持树,不只一层扁平行)  
+- 子件物料号、名称、单位、用量、损耗率、固定损耗、工序、是否虚拟件、是否反冲、替代组  
+- `item_resolve_status`:`MASTER`(已在物料主数据)/ `TEMP`(临时件,有描述无正式编码)  
+- 行号、备注、来源行 Id(迁移/入站)
+
+虚拟件继续用现口径(现网 `erp_cls=4` 映射为 `is_phantom`),展开策略仍写在**同一个** `MaterialRequirementCalculator`,条件不得散落到 ETL。
+
+### 3.3 订单行扩展(`crm_seorderentry` 或并列绑定表)
+
+| 字段 | 说明 |
+|------|------|
+| `bom_mode` | `STANDARD` / `NON_STANDARD` |
+| `bom_revision_id` | 已绑定的 **RELEASED** 修订;未绑则为空 |
+| `bom_bind_time` | 何时钉死 |
+
+`BomNumber` 字符串降为展示/兼容入站,**解析不再** `ORDER BY ic_bom.Id DESC`。
+
+建议绑定表独立(`ado_order_line_bom_bind`),避免把修订生命周期塞进订单行过宽的变更。历史换绑留痕。
+
+### 3.4 工单冻结整树
+
+新表 `ado_work_order_bom_freeze`(头+行),下达或首次展开成功时由业务服务写入**整棵闭包**(物料、用量、损耗、层级、来源 `revision_id`)。
+
+`WorkOrdDetail.FrozenBOMQty` 保留给 165 过程 `CEILING(FrozenBOMQty × Qty)`,由冻结树汇总回写,**不再单独当结构真源**。
+
+### 3.5 变更单 `ado_bom_ecn`
+
+- 目标修订(新 DRAFT)、影响范围(订单行 / 工单 / 占用 / 未清 PO)  
+- 策略:`NEW_ORDERS_ONLY`(默认)/ `APPLY_OPEN`(指定在制)  
+- 批准后发布新修订并按策略换绑  
+
+### 3.6 物料主数据
+
+- 正式件:`ItemMaster` 仍是物料权威(本方案不改成「物料也只活在中台」)。  
+- 临时件:允许组件行无 `ItemMaster`;**发布为制造 RELEASED** 时必须转正或自动建受限物料(编码规则产品定)。  
+- `BomVersion` / `EnableBomVersion` 字段废弃为展示,改由 `ado_bom_revision` 表达。
+
+### 3.7 产品设计单
+
+`ado_product_design` 保留为跟踪单(合同、图号、周期、常规/非标)。  
+`ado_product_design_bom` **不再自己当树的权威**:设计编辑直接写 `owner_type=DESIGN` 的 `DRAFT` 修订;表可迁完后只读归档或删除写路径。
+
+---
+
+## 4. 两种模式如何走(同一引擎)
+
+### 4.1 标准 `STANDARD`
+
+1. S0 维护目录 `DRAFT` → 发布为 `CATALOG + RELEASED`(填生效窗)。  
+2. 订单行模式默认 STANDARD。  
+3. 解析:`item_num + 需求日(或交期)` 命中唯一 CATALOG RELEASED;写入 `bom_revision_id`。  
+4. 以后目录再发布,**已绑定行不自动跳版本**。  
+5. 无命中:警告「无已发布目录 BOM」,与今天「未匹配到 BOM」同类,但不会误吃一张无关的最新 `ic_bom`。
+
+### 4.2 非标 `NON_STANDARD`
+
+1. 订单行标记非标(可来自产品设计 `ProductKind=2` 或订单录入)。  
+2. 设计单从目录或近单 **复制** 为 `DESIGN` 草稿,反复改。  
+3. **发布到订单**:克隆为 `ORDER_LINE + RELEASED`,绑定该行。  
+4. 评审:无 `bom_revision_id` → 状态「待设计/待发布」,**禁止**回落目录最新。  
+5. 再改:只动新草稿;要进制造必须再发布(新 `revision_code`)+ 变更单决定是否换绑已下达工单。
+
+### 4.3 展开器(唯一)
+
+`MaterialRequirementCalculator` 只接收 `revision_id`:
+
+- 读 `ado_bom_component`;  
+- 虚拟件下钻仍只对 phantom;  
+- 子阶若是标准件,按其物料在需求日解析 **CATALOG** 修订(非标整机下面可以挂标准子装配);  
+- 子阶若仍是订单专属,必须在同一冻结树或显式子修订里,禁止再猜。
+
+深度上限、损耗公式、齐套排除规则保持一处实现。
+
+---
+
+## 5. 三轨如何关掉
+
+| 旧轨 | 迁入 | 切流后 |
+|------|------|--------|
+| `ic_bom` / `ic_bom_child` | 每条头 → 一条 CATALOG RELEASED(无生效窗则 `from=最小日期`);子件 → component | 制造**停止 SELECT**;可留只读兼容视图一版,随后废弃 |
+| `ProductStructureMaster` (+ Op) | 与 `ic_bom` **对账合并**(见 §8):同物料冲突出差异报告,人工/规则裁定后写入同一 CATALOG 修订;生效窗以 PSM 为准(旧 MES 真用过) | S0 页改读/写 `ado_bom_*`;本表不再作为维护真源。165 上的同名表改由**出站**覆盖(§7) |
+| `ado_product_design_bom` | 每张设计单 → DESIGN DRAFT(或已发布过的则 ORDER_LINE) | 设计页改修订 API;旧表停写 |
+
+切流门禁(同时满足才摘旧读):
+
+1. 评审/齐套/工单生成/领料计划量 **零引用** `ic_bom`、`ProductStructureMaster`。  
+2. S0 标准 BOM 页 **零写** `ProductStructureMaster`。  
+3. 产品设计保存 **零写** `ado_product_design_bom`(或仅双写归档且制造不读)。  
+4. 对账:抽样订单展开闭包与切流前基线一致(标准样本)+ 非标样本只吃绑定修订。
+
+M-04 的「`mdp_stg_bom` → `mdp_std_bom`、评审只读标准层」**吸收为本方案**:标准层是权威表的抽取面,或与权威同键的只读投影,**不是**第二份可手改结构。
+
+---
+
+## 6. S0 与数据中台闭环(一并解决 UAT-INT-006 / 003)
+
+原则不变:页面和发布是业务动作;ETL 只抽取。
+
+| 方向 | 终态 |
+|------|------|
+| S0 / 发布 → 中台 | 作业从 `ado_bom_revision` / `component` 贴源 → `mdp_std_bom*` → 看板/诊断 |
+| 第三方 → S0 / 制造 | 入站贴源 → **登记发布器** upsert 权威修订(目录草稿或按配置直接 RELEASED)→ S0 页读权威 → 评审读权威。通道与发布器见 [`第三方标准API推数入站方案.md`](./第三方标准API推数入站方案.md) 发布平面(`publish_mode=AUTHORITY`);未建本方案权威表则 BOM 对象保持不开通 |
+| 现 `S3_ITEM_ERP` | 物料仍:外部/本库 `ItemMaster` 为物料权威;BOM 不再从 `ItemMaster` 旁路猜 |
+
+禁止:
+
+- 只进 `mdp_std_item` 就算「S0 同步成功」;  
+- S0 继续写 `ProductStructureMaster` 而中台抽另一张;  
+- 用转换 SQL 当「发布按钮」。
+
+物料主数据回灌(`ItemMaster`)与 BOM 回灌同批设计、可分步上线,但 **BOM 不得只回灌中台**。UAT-INT-003 的物料/客户/供应商回灌仍按对象拍板;本方案强制 **BOM 必须回灌权威或与权威同表**。
+
+---
+
+## 7. 165 / MES / 领料
+
+C1:不加 165 表、不改过程。
+
+- 投产过程仍读 165 `ProductStructureMaster`、`FrozenBOMQty` 的,由 Outbox **从本库已发布/已冻结树生成 165 行**(字段级共管,禁止覆盖 WMS 已领列)。  
+- 方向与旧 `SyncBom` **相反**:以前是 165 → 计划库;终态是 **计划权威 → 165 执行镜像**。  
+- 订单非标树:按下达工单的冻结闭包推 165,保证现场领的是本单结构。  
+- `QtyRequired = CEILING(FrozenBOMQty × Qty)` 逐字保留,用量来自冻结树。
+
+工单物料中途变更(UAT-INT-001 已做领料回写)之后,结构变更必须走 ECN,再重冻、再出站,不得静默改冻结树。
+
+---
+
+## 8. 迁移与对账
+
+### 8.1 目录合并(最难、必须做)
+
+对每个 `item_num`:
+
+1. 从 `ic_bom` 生成候选树 A。  
+2. 从 `ProductStructureMaster` 按当前生效窗生成候选树 B。  
+3. 结构哈希比较:相同则一条 RELEASED,生效窗取 B。  
+4. 不同则进入差异清单:默认 **不自动覆盖**;规则可配置「B 优先(MES 生效窗)/ A 优先(评审正在用)」但必须出报告。  
+5. 评审基线:切流前对活跃订单跑展开快照,切流后绑定到合并得到的 revision,差异超阈阻断。
+
+### 8.2 设计单
+
+有合同/订单号的:生成 DESIGN 草稿,不自动当制造已发布(避免把跟踪用树突然送进齐套)。  
+需要进入制造的,实施窗口内人工或脚本「发布到订单」。
+
+### 8.3 双写窗口(只为切流,不是终态)
+
+允许短暂:写 `ado_bom_*` 同时回灌旧表,供未切完的只读报表。窗口关闭条件见 §5 门禁。**禁止**把双写写成长期架构。
+
+---
+
+## 9. 应用改造范围(按模块)
+
+| 模块 | 改什么 |
+|------|--------|
+| S0 | 标准 BOM 页改为修订+发布+生效窗+版本列表;停写 PSM;选料仍校验 `ItemMaster`(临时件行例外) |
+| S1 产品设计 | 树编辑对接 DESIGN 草稿;增加「发布到订单」;`ProductKind` 驱动默认 `bom_mode` |
+| S1 订单 / 评审 | 行上模式与绑定;解析改 `revision_id`;计算器换数据源;资源检查结果仍写 `b_bom_child_examine`(内容来自新树) |
+| S2 工单 / 排程 | 生成时写冻结整树;齐套读冻结或绑定修订(已下达优先冻结) |
+| S3 齐套 / 供应 | 需求展开与占用跟同一 `revision_id` / 冻结树 |
+| 领料 / 165 | 冻结树 → `FrozenBOMQty` + 出站 PSM/明细 |
+| MDP | 新实体 `S0_BOM_REVISION` / `S0_BOM_COMPONENT`;废止制造直查 `ic_bom`;入站 upsert 权威 |
+| S8 / KPI | 产品设计延期口径不变;可增加「待发布 BOM」作为交期风险事实(可选) |
+
+新菜单(实施时按 FUNC 规则**新号登记**,不改已有 `FUNC-S0-001` 编号;`Title` 仍短名):
+
+- 目录 BOM 版本 / 发布(可并入现标准 BOM 页,不必新号)  
+- 订单制造 BOM(只读本单树 + 从设计发布)  
+- BOM 变更单  
+
+隐藏路由(详情、发布确认)一并登记。
+
+---
+
+## 10. 实施阶段(全是主路径上的切片,没有「先凑合」阶段)
+
+阶段可以并行准备,**对外切流只有一次**(制造读路径切换)。
+
+| 阶段 | 内容 | 完成定义 |
+|------|------|----------|
+| A 模型与发布服务 | 表、状态机、复制/发布 API、权限 | 单测:发布不可变、窗口冲突拒绝、复制血缘 |
+| B 迁移与对账 | ic_bom ∪ PSM ∪ 设计单 → 修订;差异台账 | 活跃物料 100% 有裁定;基线快照入库 |
+| C 计算器与绑定 | 解析 + 展开只认 revision;订单行字段 | 标准样本展开与基线一致;非标无绑定不回落 |
+| D S0 / 设计 UX | 页面对接权威;停写旧表 | 手工路径走通发布 |
+| E 冻结整树 + 165 出站 | 工单冻树;Outbox 镜像 | 165 过程用量与冻树一致 |
+| F 中台闭环 | 贴源/标准层/入站回灌权威 | S0 页与看板同一物料+BOM;UAT-INT-003/006 对 BOM 关闭 |
+| G 变更单 + 临时件转正 | ECN、TEMP 发布门禁 | 投产后改结构必走单 |
+| H 摘旧轨 | 删制造 SQL、只读视图下线、双写关 | §5 门禁全绿 |
+
+G 不后置成「有空再做」:无 ECN 则双模在投产后仍会脏改。可与 E 同发,但不得取消。
+
+---
+
+## 11. 跨模块影响(确认前不编码)
+
+| 波及 | 可能影响 |
+|------|----------|
+| S0 | 标准 BOM 从「行维护」变为「修订/发布」;PSM 停写 |
+| S1 | 订单行绑定、评审失败语义、设计发布 |
+| S2 | 工单冻树、排程齐套输入 |
+| S3 | 缺料/占用按绑定树 |
+| S4/S5 | 领料数量来源;165 镜像方向反转 |
+| MDP | 新实体、入站写权威、看板口径 |
+| 旧 M-04 | **并入本方案后关闭**,不再平行做「只建 std 不切评审」 |
+| FUNC / 种子菜单 | 新页与隐藏路由登记 |
+
+未按协作规则确认本方案前,**不改**评审展开、S0 CRUD、MDP 转换、165 出站。
+
+---
+
+## 12. 仍须产品拍板(架构已定,规则取值未定)
+
+| # | 问题 | 方案默认(可改) |
+|---|------|------------------|
+| 1 | 目录合并冲突谁优先 | 出报告;默认不自动覆盖,实施期裁定 |
+| 2 | 临时件发布是否自动建 `ItemMaster` | 必须转正才能 RELEASED 到制造;自动建受限物料为可选项 |
+| 3 | 非标是否允许「先评目录、后换本单树」 | 默认否;要则必须走 ECN 换绑 |
+| 4 | 入站目录 BOM 默认 DRAFT 还是直接 RELEASED | 外部为真源时直接 RELEASED + 生效窗;本库自维走草稿发布 |
+| 5 | 已下达工单能否被 ECN 换树 | 默认否,仅未下达;`APPLY_OPEN` 要单独权限 |
+| 6 | CTO 超级 BOM | 本方案不实现,模型不堵死 |
+
+---
+
+## 13. 验收提纲
+
+1. 标准:发布目录 V2 后,已绑定 V1 的订单再评仍为 V1;新单按交期吃 V2。  
+2. 非标:未发布评审停在待设计;发布后展开等于设计树;再改设计未发布,制造不变。  
+3. 三轨:S0 改权威可见于评审;改已停写的 PSM **不再**影响制造;设计未发布 **不影响** `ic_bom` 时代误伤(旧表已不读)。  
+4. 中台:外部 BOM 入站后 S0 列表与评审都能用;看板 `mdp_std_bom*` 与权威一致。  
+5. 冻结:改目录 V3 后,已下达工单领料仍按冻树;165 `FrozenBOMQty` 与冻树单位用量一致。  
+6. ECN:批准并 APPLY 后仅清单内工单换树;其他订单不动。  
+7. 回归:同物料多层虚拟件、替代料、无 BOM、齐套排除规则与切流前标准样本一致。
+
+---
+
+## 14. 与已有文档的关系
+
+| 文档 | 关系 |
+|------|------|
+| 标准/非标适用情况分析 | 问题与场景;本文给目标解 |
+| S0 未走数据中台 | 双向断;本文 §6 关闭 |
+| UAT-INT-003/005/006 | 实施完成后对应关闭或缩到「物料/客户回灌」等非 BOM 残留 |
+| M-04 / P-002 | **并入本文**,不要另做「只建标准层」 |
+
+---
+
+## 15. 本文件明确不做
+
+- 不在未确认时改代码、加表、迁数据。  
+- 不写「先 SyncBom 再评估双模」的过渡架构。  
+- 不恢复 Mongo、不改 165 过程。
+
+---
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-09-06 | 初版:统一修订权威、双模生命周期、三轨迁入与摘除、中台闭环、165 出站反向、分阶段与验收 |

+ 1 - 1
doc/plan/Ai-DOP剩余待办统一执行任务书_20260814.md

@@ -257,7 +257,7 @@ P-010、P-012、P-013、P-014、P-017、P-026、P-031、P-033 已完成。除非
 **执行步骤**
 
 1. 重新采集当前 Top 表、日增量和索引体积,不能沿用 7 月数据直接定阈值。
-2. 建统一保留配置和清理作业,首批覆盖 `mdp_sync_log`、`mdp_transform_run_log`、`SysLogOp`。
+2. 建统一保留配置和清理作业,首批覆盖 `mdp_sync_log`、`mdp_transform_run_log`、`SysLogOp`。**入站预登记(2026-09-11,P-2 WP-F)**:`mdp_inbound_envelope` 30 天(`create_time`);`mdp_inbound_request` 仅 `COMMITTED`/`FAILED` 且 ≥7 天(先删 envelope 再删 request;`RECEIVED` 不删)。统一作业落地时一并纳入,本任务书不另写清理 Job。
 3. 清理按小批次执行,记录删除量和耗时,避免长事务。
 4. 评估 `dwd_process_outsource_delivery` 分区/滚动;先做影子验证再改生产表。
 5. 与 P-015 窗口留证联动,确认调度强度不会让清理追不上增长。

+ 4 - 1
doc/plan/Ai-DOP项目待办清单.md

@@ -55,7 +55,7 @@
 | P-008 | T-S0 | 🔶 部分完成 | P1 | CodeFirst 已有“单实体失败继续启动”等缓解;legacy BOM 根因治理、历史 NULL 回填、空库/存量库重复启动验收未完成 | [`S0-CodeFirst启动异常与BOM历史数据治理方案.md`](./S0-CodeFirst启动异常与BOM历史数据治理方案.md);统一任务书 E-008 |
 | P-009 | T-KPI | 🔶 部分完成 | P2 | KPI 原子表、构建/查询服务和部分维度 KPI 已落;S1–S9 全量筛选矩阵、诊断同口径与不支持项 UI 提示未完成 | [`KPI原子聚合层与全局筛选适配执行任务书.md`](./KPI原子聚合层与全局筛选适配执行任务书.md);统一任务书 E-009 |
 | P-010 | T-PERF | ✅ 已完成 | P0 | 订单评审 / 生成物料需求末尾全量数据中台 ETL 改后台异步 + 单飞去抖;§6 验证通过(评审 ~3s,ORDER_REVIEW 后台 SUCCESS,3 次连评仅 2 轮全量) | [`S1-评审与物料需求-数据中台全量刷新异步化任务书.md`](./S1-评审与物料需求-数据中台全量刷新异步化任务书.md) |
-| P-011 | T-CAP | ⬜ 待办 | P1 | 库容量长期可控治理:P0 日志保留闭环(`mdp_sync_log`/`mdp_transform_run_log`/`SysLogOp`)、P1 大表 `dwd_process_outsource_delivery` 分区滚动、P2 调度强度与 KPI 原子日收口;统一保留配置 + 清理作业。**待定保留窗口取值与"丢弃/先归档"策略** | [`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4 |
+| P-011 | T-CAP | ⬜ 待办 | P1 | 库容量长期可控治理:P0 日志保留闭环(`mdp_sync_log`/`mdp_transform_run_log`/`SysLogOp`)、P1 大表 `dwd_process_outsource_delivery` 分区滚动、P2 调度强度与 KPI 原子日收口;统一保留配置 + 清理作业。**待定保留窗口取值与"丢弃/先归档"策略**。**入站预登记(WP-F / 2026-09-11)**:`mdp_inbound_envelope` 保留 **30 天**(按 `create_time` delete);`mdp_inbound_request` 仅 `COMMITTED`/`FAILED` 且 `update_time` ≥ **7 天**(须 ≥ Idempotency-Key 去重窗;先删对应 envelope 再删 request;`RECEIVED` 进行中行不删) | [`aidopdev_165库容量与增量写入分析_20260716.md`](./aidopdev_165库容量与增量写入分析_20260716.md) §4 |
 | P-012 | T-DUAL | ✅ 已完成 | P0 | S5–S7 KPI 租户参数化:`TargetTenantId/TargetFactoryId`(默认仍 1300000000001/1);看板 refresh 支持 query 传租户;server 1.0.253 | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §7 FIX-1 |
 | P-013 | T-DUAL | ✅ 已完成 | P1 | 执行器+Writer+调度;健康检查 Job;sources 真表;参数/公式 API+UI(server `1.0.264` / Web `2.4.258`) | [`AIDOP双模式对接执行任务书.md`](./AIDOP双模式对接执行任务书.md) §3;交付级任务书进度看板 |
 | P-014 | T-DUAL | ✅ 已完成 | P1 | S5–S7 e2e+std;S1–S4 stg+std 契约;S6 报工 stg/std+契约;有 stg `_API` 齐。残留仅真实源外置 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) |
@@ -90,6 +90,7 @@
 | P-043 | T-SD | ⬜ 待办 | P1 | 本机 / `aidopdev` `AutoVersionUpdate` 对账:远端 `1.0.358`–`1.0.362` 已是 FQC/MRP;本地曾用同号跑智慧诊断脚本,启动可能哈希不一致。智慧诊断脚本现为 `1.0.363`–`1.0.368`。**禁止改脚本对齐哈希**,按现网脚本号对账或隔离输出目录 | 留证 README;提交说明 `7cc24aa0d` |
 | P-044 | T-TENANT | ⬜ 待办 | P2 | `FqcInspBillFlowService` 出站补 `tenant_id` 过滤仍在工作区,**未提交**。单独开项,勿与智慧诊断或他人 FQC 提交混入 | `server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillFlowService.cs` |
 | P-045 | T-UAT-3SET | 🔶 部分完成 | P0 | 三套 UAT 未收口:A 五组与智慧诊断接口闭环已有留证;**B 从零点通、Demo 九宫格、三租户 DWD/KPI 构建对账、全量 FUNC/稳定性复跑**尚未宣布完成。ChatBI 见 P-041,数据缺口见 P-039 | [`UAT测试数据准备/11-总执行任务书.md`](./UAT测试数据准备/11-总执行任务书.md) |
+| P-046 | T-DUAL | ⬜ 待办 | P0 | **API_INBOUND 发布收口**(P-2 A–F 编码已落 server `1.0.538`,指南第 4 章已写)。剩:① **G1** 三实体字段会签 + T8 流水/期初/结存书面答复(F1–F5);② 开发库补 `mdp_inbound_grant` 后跑 V10–V14 / Mock;③ 阶段 H 按实体矩阵逐个开白名单(切换即停该对象 PULL);④ S0 页镜像只读化(前端另做);⑤ P-011 入站表实际清理作业。不做:目录 BOM 推送、降级拉数、改对外 KPI 口(P-023) | [`第三方标准API推数入站执行任务书.md`](./第三方标准API推数入站执行任务书.md) §1/§16;[`UAT测试/内部UAT测试待办项.md`](./UAT测试/内部UAT测试待办项.md) UAT-INT-003/004 |
 
 **图例**:⬜ 待办 · 🔄 进行中 · 🔶 部分完成 · ✅ 已完成 · ⏸ 暂缓 · ❌ 取消
 
@@ -120,6 +121,7 @@
 11. **P-041**:ChatBI(全部前述收口之后,最后做)
 12. **P-042**:HOLD,不排期开发
 13. **P-044 / P-002~P-006 / P-008 / P-020 / P-021 / P-027 / P-034**:按统一任务书依赖顺序排期
+14. **P-046**:方式丙发布收口(G1 会签 → 联调 V10–V14 → 逐实体开通);指南已写,未会签前契约保持 v1-draft
 
 ---
 
@@ -172,3 +174,4 @@ doc/plan/Ai-DOP项目待办清单.md
 | 2026-08-17 | 登记专题 **T-SD** 与 **P-038~P-045**:智慧诊断 WP-SD0–SD8 代码已推但未宣布交付;数据缺口、S9 树决策、ChatBI、WP-SD9 HOLD、脚本号对账、未提交 FQC 过滤、三套 UAT 收口。P-037 页面验收并入 P-038 |
 | 2026-08-18 | **P-039 口径调整**:不要求真实业务链,允许自行编制测试数据补缺,目标是三租户诊断/改善闭环测通;仍须租户隔离,不得冒充生产事实 |
 | 2026-08-18 | **P-039 测通**:A/S8、B/S6、B/S7、Demo/S5 接口闭环均关闭;种子脚本 `run_wp_sd_p039_seed.py`;发运/评审明细表未补 |
+| 2026-09-11 | 新增 **P-046**:API_INBOUND 发布收口(G1 / 联调 / 阶段 H / S0 只读页)。集成指南第 4 章已按方式丙终态写入 |

+ 257 - 0
doc/plan/ChatBI改善方案.md

@@ -0,0 +1,257 @@
+# ChatBI 改善方案
+
+> 文档定位:FUNC-S9-007 ChatBI 智能问答的产品定位、边界与分期改善(含操作指引)  
+> 创建日期:2026-09-04  
+> 关联功能:FUNC-S9-005 九宫格、FUNC-S9-006 智慧诊断、FUNC-S9-017 改善计划台账  
+> 相关代码:`server/Plugins/Admin.NET.Plugin.AiDOP/ChatBI/`、`Web/src/views/aidop/chatbi/`  
+> 相关方案:[动态运营详情看板与指标改善闭环方案.md](./动态运营详情看板与指标改善闭环方案.md)
+
+---
+
+## 一、背景与问题
+
+ChatBI 当前挂在九宫格 / 模块看板浮窗(独立路由 `/aidop/chatbi` 为规划占位)。2026-09 一轮改造后,已具备:
+
+- 四类问题口径:**数据查询 / 指标好坏对比 / 问题定位 / 问题原因和如何改善**
+- DeepSeek **先判类型**,失败回退关键词;「来自哪些订单 / 怎么算」强制归入数据查询
+- 后端只读看板已算出的 KPI 日值,大模型不编数字、不生成 SQL
+- 未识别问题时提示可问范围,不再默认拿一盏红灯硬答
+
+真人使用后仍觉得**鸡肋**。根因不是文案,而是产品位置错了:
+
+1. **复读看板。** 「物料计划满足率什么情况」只是把卡片上的 68.97% / 目标 / 红灯再说一遍。
+2. **该下钻时做不到。** 「来自哪些订单」只能提示「列不出构成单据」,用户感觉功能虚。
+3. **该闭环时是空话。** 「下一步创建改善计划」没有动作,诊断四段(结论 / 证据 / 原因 / 下一步)像套话。
+4. **和大模型的关系被高估。** 分类和润色已接上,但没有把「选指标 → 对比变化 → 送到诊断」串成对话优势。
+
+本方案确认:**ChatBI 不做成第二套智慧诊断,也不在对话里创建改善计划草稿。**  
+同一浮窗可兼做**系统操作指引**(不知道去哪、怎么点),但必须与指标问答分成不同意图,禁止混用选数逻辑。
+
+---
+
+## 二、产品定位
+
+看板的优势是扫一眼九宫格。智慧诊断的优势是盯住一个指标把原因、单据、计划做完。ChatBI 的优势应是:**用一句话完成「听懂问题 → 点出该看哪一个指标或哪一页 → 送到该去的页面」**。
+
+同一入口两件事,分类必须分开:
+
+| 用户在问 | ChatBI 意图 | 答什么 | 不答什么 |
+|----------|-------------|--------|----------|
+| 指标多少 / 谁好谁差 / 哪最严重 / 为什么红 | 指标问答(现有四类) | 看板事实 + 去诊断 | 操作手册长文 |
+| 功能在哪 / 怎么点 / 审批后谁开始执行 | **操作指引 `how_to`** | 菜单名、FUNC、路径、一句话步骤 + 打开该页 | KPI 红灯、单据、建计划 |
+
+| 能力 | 九宫格 / 模块看板 | ChatBI | 智慧诊断 | 改善计划台账 |
+|------|-------------------|--------|----------|--------------|
+| 角色 | 看现状 | 入口:指标导诊 + 操作指引 | 下钻工作台 | 计划执行与审批 |
+| 用户状态 | 扫盘 | 还在看板,刚问了一句(问数或问路) | 已决定盯这个指标 | 已决定立项 |
+| 该给什么 | 卡片数值、颜色、趋势 | 一句话结论 + 跳转(页面或诊断) | 下级指标、证据、建草稿 | 审批、开始执行、分派 |
+| 建改善计划 | 否 | **否** | 是(`createFromDiagnosis` → `draft`) | 是(送审后流程) |
+
+一句话:
+
+- **看板**回答「现在多少」
+- **ChatBI(指标)**回答「该看哪一个指标、去诊断还是看板」
+- **ChatBI(操作指引)**回答「功能在哪、点哪、哪条菜单」
+- **智慧诊断**回答「为什么、哪些单据、怎么立项」
+- **改善计划**回答「谁批、谁执行」
+
+「改善计划草稿」是台账里状态为 `draft`、尚未送审的计划。它只应在智慧诊断(或台账手工创建)产生,ChatBI **不调用** `createFromDiagnosis`。
+
+---
+
+## 三、当前能力与硬边界
+
+### 3.1 已落地(保持)
+
+- 意图:DeepSeek 封闭枚举 `data_query | compare | locate | diagnose | how_to | unrecognized`(`how_to` 为 P0.5 新增),关键词回退
+- 选数:后端按看板 KPI 卡片选焦点,不把无关模块红灯拉进来
+- 数据查询:给聚合值;问来源 / 怎么算时说明当前链路没有单据明细
+- 未识别:提示四类问法,空指标列表
+- 动作:跳转模块看板;诊断类可带「打开智慧诊断」(带 `module` / `metricCode`)
+
+### 3.2 硬边界(持续有效)
+
+- 大模型**只判类型、写人话**,不编数值、不编订单号、不写 SQL、**不编菜单路径或操作步骤**
+- 指标事实只来自看板 KPI 日值(及必要的已有诊断证据接口,见 P1)
+- 操作指引事实只来自已登记菜单 / `FUNC_DEFS`(`Web/src/constants/aidopFuncCodes.ts`)及本方案写死的短步骤,禁止模型自由发挥「点哪里」
+- **不**在 ChatBI 内创建、送审、开始执行改善计划
+- **不**把对话做成可编辑的证据台或第二套诊断页
+- 不是所有 KPI 都有行级单据。物料计划满足率(`S3_L1_002`)实际来自 `dwd_supplier_delivery` 采购交货行 / PO,不是销售订单;人效、库存周转等只有聚合。说不清口径时必须直说,禁止用销售订单冒充
+
+### 3.3 四类问题的用法
+
+四类问题是**教用户怎么问**的框架,不是产品主形态,禁止为每一类再做一套万能长模板。
+
+| 类型 | 对话里给到什么 | 送到哪里 |
+|------|----------------|----------|
+| 数据查询 | 当前值 / 目标 / 状态;问来源则一句口径 +(P1 后)Top N 预览 | 模块看板;有单据则智慧诊断 |
+| 好坏对比 | 最好与最差各 1 个,点明差在哪一层 | 对应模块看板 |
+| 问题定位 | 只点 1 个主瓶颈,最多 1 个次风险 | 智慧诊断(带指标) |
+| 原因与改善 | 下级红黄指标摘要 +「去诊断看证据并建计划」 | 智慧诊断,由诊断建草稿 |
+| 操作指引 | 功能短名 + FUNC + 一句话「去哪、点什么」+ 打开该页 | 对应业务页 / 诊断 / 台账 / 工作台 |
+
+「怎么改善物料计划满足率」仍是**原因与改善**(去诊断)。  
+「改善计划在哪录入」「审批通过后怎么开始执行」才是**操作指引**。
+
+---
+
+## 四、目标体验
+
+### 4.1 打开即播报(P0)
+
+打开九宫格 ChatBI 时,不等用户猜提示词,先给 2~3 条:
+
+- 谁刚转红或最偏离目标
+- 谁相对最好(绿灯优先)
+- 与当前筛选日期一致
+
+示例:「当前最需要关注的是物料计划满足率 68.97%(目标 95%,红灯)。整体较好的是库存周转。点「打开智慧诊断」可看构成单据。」
+
+这是看板扫一眼得不到的「今天先看谁」,也不侵犯诊断职责。
+
+### 4.2 追问有上下文(P0)
+
+同一浮窗会话记住上一问的焦点指标与模块。  
+「那为什么红了」「这些单子怎么看」应接着当前指标,不要重新判成无关的全局瓶颈。
+
+### 4.3 证据预览,不是证据台(P1)
+
+仅当用户问来源 / 哪些订单 / 怎么算,且该指标在智慧诊断已有证据注册时:
+
+- 复用诊断已有查询,列出最多 5 行(单号、物料、状态)
+- 文案写清构成单位(PO / 工单 / 交货行),不说「销售订单」除非口径就是
+- 主按钮仍是「打开智慧诊断」看全量、建计划
+- 无行级依据的指标:明确「只有聚合,没有单据」,不要套话挡回去
+
+ChatBI 只做**预览**。完整筛选、贡献度、跳转业务页留在智慧诊断。
+
+### 4.4 诊断入口要带参数(P0 补齐)
+
+问题定位、原因与改善的跳转必须带上 `module` 与 L1 `metricCode`,落到诊断页已选中的指标,避免用户再找一遍。
+
+### 4.5 操作指引:不会用系统也可以问(P0.5)
+
+把 ChatBI 当成**系统智能助理的入口之一**,不是另做一套帮助中心站点。
+
+用户可能问:
+
+- 「改善计划在哪」→ FUNC-S9-017,打开台账  
+- 「审批通过后谁点开始执行」→ 说明仅总负责人,并链到台账 / 工作台  
+- 「智慧诊断怎么打开」→ FUNC-S9-006  
+- 「物料计划满足率要去哪下钻」→ 智慧诊断并带上该指标(指引里给路径,不在此列单据)
+
+规则:
+
+1. 分类单独走 `how_to`,**不选 KPI、不套诊断四段**。  
+2. 匹配优先:菜单 Title / FUNC 名 / 已登记 `paths`;对不上则提示「可以说功能名,例如改善计划、智慧诊断」,不要猜一个红灯。  
+3. 步骤只写已经实现的交互(例如:审批通过后总负责人点「开始执行」才会分派)。未实现的能力直说没有。  
+4. 无权限的菜单:说「你当前账号可能看不到该菜单」,仍给功能名,不编旁路。  
+5. 与会话上下文独立:上一问在聊某个 KPI 时,用户改口「改善计划在哪」应切到 `how_to`,不要继续答该 KPI。
+
+---
+
+## 五、明确不做
+
+1. ChatBI 内创建改善计划草稿、代送审、代点「开始执行」
+2. 对话里完整下钻 L2–L4 树、可筛选证据表、替代诊断页
+3. 让大模型生成 SQL 或自由查库
+4. 把所有未识别问题默认答成全局最差红灯
+5. 承诺「每个 KPI 都能列出销售订单」
+6. 新增独立 ChatBI 站点或改九宫格 / 诊断公共 API 口径(除非 P1 复用诊断证据时只读已有接口)
+7. 本期不改 8888 / 5005 端口约定
+8. 操作指引不写未上线功能的操作步骤,不替代培训手册长文(只给「去哪一页 + 关键一步」)
+9. `how_to` 命中后禁止再跑指标选数与红灯播报
+
+---
+
+## 六、分期实施
+
+只动 ChatBI 前后端与诊断只读复用。不改看板 KPI 计算口径,不改改善计划审批流。
+
+### P0:不再鸡肋的入口(优先)
+
+| 项 | 说明 | 主要位置 |
+|----|------|----------|
+| 打开即播报 | 浮窗打开自动问一次「当前该看什么」,或后端提供 overview 摘要 | `ChatBIPanel.vue`、`ChatBIService.AskAsync` |
+| 会话上下文 | 前端带上一条 `focusMetricCode` / `moduleCode`;后端优先沿用 | `ChatBIAskInput`、`ChatBIService` |
+| 诊断深链 | `locate` / `diagnose` 动作带指标参数 | `BuildActions`、诊断页读 query |
+| 文案收敛 | 数据查询短答;诊断类一句话 + 跳转,去掉空的「去建计划」套话 | `ChatBIService`、`ChatBIMessage.vue` |
+
+**跨模块:** 诊断页需能消费 `module` + `metricCode`(若已有则只补 ChatBI 侧)。不改诊断计算。
+
+### P0.5:操作指引(与 P0 可并行)
+
+| 项 | 说明 | 主要位置 |
+|----|------|----------|
+| 分类增 `how_to` | DeepSeek 枚举与关键词(在哪、怎么打开、怎么操作、菜单) | `ChatBIService`、分类提示词 |
+| 功能检索 | 用 `FUNC_DEFS` + 侧栏已注入的 path/name 做匹配,返回 1 个主入口,必要时 1 个次入口 | `aidopFuncCodes.ts`(只读);后端可镜像一份短表或由前端根据 intent 跳转 |
+| 短步骤库 | 仅覆盖高频已上线路径(改善计划送审 / 开始执行、打开诊断、九宫格、工作台待办),条数可控 | ChatBI 内常量表,不写成长篇 FAQ |
+| 面板提示 | 浮窗说明增加「也可问功能在哪、怎么操作」;示例加一条操作问法 | `ChatBIPanel.vue` |
+
+**跨模块:** 只读菜单与 FUNC 映射,不改 `sys_menu.Title`、不改方案 A 展示。跳转走现有路由。若后端不便引用前端常量,可在 ChatBI 维护一份与 `FUNC_DEFS` 同步的短表,禁止两套编号。
+
+### P1:来源预览
+
+| 项 | 说明 | 主要位置 |
+|----|------|----------|
+| 复用证据注册表 | `data_query` 且问来源时调用智慧诊断已有证据查询,Limit=5 | `SmartDiagnosisEvidenceRegistry`(只读) |
+| 口径说明 | 按 metric 写清 PO / 工单 / 仅聚合 | ChatBI 回答段落 |
+| 无证据降级 | 无注册或 0 行:只说聚合 + 去模块详情,不编单号 | 同上 |
+
+**跨模块:** 只读诊断证据,不改注册 SQL、不改诊断页交互。需在实现前再确认一次接口调用方式。
+
+### P2:播报质量(可选)
+
+- 与上一统计日对比「转红 / 转绿」(仍用已有日值表,不新开明细)
+- 好坏对比同时给最好与最差各一条,并点名差在哪条 L2(仅当该 L1 有下级日值)
+
+---
+
+## 七、技术约束
+
+1. **分类:** DeepSeek 封闭 JSON(含 `how_to`);解析失败或未配置则关键词回退;「来自哪些 / 怎么算」强制 `data_query`;「在哪 / 怎么打开 / 怎么操作」优先 `how_to`(与「怎么改善某指标」区分:后者有指标名 + 改善/原因)。
+2. **选数:** 命名指标后不得改成其它模块红灯;会话有焦点时**指标类**追问不得清空焦点;`how_to` 不走选数。
+3. **事实:** KPI 来自 `ado_s9_kpi_value_l*_day` + 主数据;P1 单据预览只走诊断已注册查询;操作指引只走 FUNC / 菜单 / 短步骤库。
+4. **回答:** 本地确定性段落兜底,DeepSeek 仅润色已选事实(含已选定的页面名与步骤,不得增步)。
+5. **权限:** 租户 / 工厂与看板、诊断同一套可信范围。
+6. **版本:** 实施时按实际提交端升 `Web/package.json` 与/或 `Admin.NET.Web.Entry.csproj` patch。
+
+---
+
+## 八、验收口径
+
+| 编号 | 场景 | 期望 |
+|------|------|------|
+| A1 | 打开九宫格 ChatBI | 未提问已看到「该关注谁 / 谁较好」,不是空白提示词墙 |
+| A2 | 「物料交货满足率什么情况」 | 只答该指标数值与状态,不拉订单评审等无关红灯 |
+| A3 | 「整体做得最好的是哪个」 | 点名绿灯或相对最好,不套诊断四段 |
+| A4 | 「当前最严重的是什么」 | 一个主瓶颈 + 打开智慧诊断(带指标) |
+| A5 | 「物料计划满足率是来自哪些订单算的」 | 归数据查询;P0 说明聚合与口径;P1 最多 5 行单据预览 + 去诊断 |
+| A6 | 「那为什么红了」(接 A5) | 仍盯物料计划满足率,不改判成全局其它红灯 |
+| A7 | 任意「怎么改善 / 建计划」 | **不**在 ChatBI 落库草稿;只跳转智慧诊断 |
+| A8 | 「今天天气怎么样」 | 未识别 + 问法示例(含操作指引),不选红灯硬答 |
+| A9 | 无行级证据的指标问来源 | 明确「只有聚合」,不编单号 |
+| A10 | 「改善计划在哪」 | `how_to`:FUNC-S9-017 + 打开台账,不出现 KPI 红灯四段 |
+| A11 | 「审批通过后怎么开始执行」 | 说明仅总负责人点「开始执行」后才分派 + 链到台账/工作台 |
+| A12 | 先问某 KPI,再问「智慧诊断怎么打开」 | 切到操作指引,打开 FUNC-S9-006,不继续答上一指标原因 |
+
+---
+
+## 九、决策记录(2026-09-04)
+
+1. 「来自哪些订单」属于**数据查询**,不是改善诊断,也不单独做成「超出范围」一类。
+2. 类型判断以大模型为主、关键词为辅;大模型不负责选数和算值。
+3. ChatBI 与智慧诊断**不重复**:对话导诊,诊断下钻并建草稿。
+4. 改善计划草稿 = 台账 `draft`,仅诊断 / 台账创建;ChatBI 不立项。
+5. ChatBI 可兼做系统操作指引(同一浮窗、独立意图 `how_to`);知识来自 FUNC / 菜单,不让模型编操作步骤。
+
+---
+
+## 十、建议实施顺序
+
+先做 **P0**(打开即播报、会话上下文、诊断深链、文案收敛),验收 A1–A4、A6–A8。  
+**P0.5**(操作指引)可与 P0 并行,验收 A10–A12。  
+再做 **P1**(证据预览),验收 A5、A9。  
+P2 视 UAT 反馈决定。
+
+动手改代码前仍按仓库约定列出文件级改动清单,确认后再补丁。

+ 177 - 0
doc/plan/S0未走数据中台的问题.md

@@ -0,0 +1,177 @@
+# S0 未走数据中台的问题
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **待讨论资料**。把 S0 运营建模与数据中台的双向断裂说清楚,**不立项、不编码** |
+| 编写日期 | 2026-09-06 |
+| 依据 | 当前仓库实现 + 中台分层硬约束,不是对接指南里的目标态 |
+| 内部 UAT | [`UAT测试/内部UAT测试待办项.md`](./UAT测试/内部UAT测试待办项.md) **UAT-INT-006**(本篇);入站未回灌见 **UAT-INT-003**;BOM 行业适配见 **UAT-INT-005** |
+| 目标方案 | [`AIDOP标准与非标双模BOM升级方案.md`](./AIDOP标准与非标双模BOM升级方案.md) §6 |
+| 相关已知债 | 项目待办 **P-002 / M-04**(`ic_bom` 与 `ProductStructureMaster` 双轨);**P-001 / P-007**(中台管道) |
+
+---
+
+## 1. 要讨论的问题
+
+S0 物料、标准 BOM、客户、供应商等页直接改本库业务表。第三方若经数据中台同步「S0 相关」主数据,只进 `mdp_stg_*` / `mdp_std_*`,**不回灌**这些业务表。
+
+需要对齐:
+
+1. 「页面写业务表」算不算违反中台原则?
+2. 中台同步进来的 S0 类数据,对运营建模和制造有没有用?
+3. 和 BOM 双轨、非标设计是不是同一笔账?
+
+---
+
+## 2. 结论(建议会上先对齐)
+
+**「S0 页面写业务表」符合中台原则;「S0 主数据未进入中台闭环」不符合。**
+
+中台要求:业务表负责发生了什么,中台从权威抽取并整理。S0 CRUD 不直接改 `mdp_std_*`,这一点是对的。
+
+现状是**双向断**:
+
+| 方向 | 现在怎样 | 结果 |
+|------|----------|------|
+| S0 → 中台 | 无 `S0-mdp/refresh`;标准 BOM 无 `mdp_std_bom` 管道 | S0 改完,看板/标准层不一定有;制造更不读 S0 BOM |
+| 中台 → S0 | 第三方入站只落到贴源/标准层,MDP 转换**没有** upsert `ItemMaster` / `ProductStructureMaster` / `ic_bom` | S0 页、评审选料、开工单校验看不见这批数。**入站半环终态**见 [`第三方标准API推数入站方案.md`](./第三方标准API推数入站方案.md) 发布平面 |
+
+因此:**中台通了 ≠ S0 和评审能用。** 看板可以有维表名称;运营建模页和制造主链路仍认本库表。
+
+旧 DOP 用 `SyncBom` 把执行库结构灌进**计划库业务表**。新中台只灌标准层,少了「回灌业务表」或「制造改读标准层」其中一步。
+
+---
+
+## 3. 中台原则对照
+
+硬约束(S1 迁移计划 / 中台复盘,表述一致):
+
+```text
+业务运行表(页面可改、事务权威)
+    → mdp_stg_* 贴源
+    → mdp_std_* 标准对象
+    → dwd_* / KPI(看板、诊断)
+```
+
+- 业务动作写业务表;**ETL 不得承载业务副作用**。  
+- 看板读标准层/DWD,不让页面改中台表。  
+- 双模式规格:物料/BOM 真源在 ERP/PLM,一般不回写对方;本库应是镜像或 `mdp_std_item`。
+
+| 做法 | 是否符合 |
+|------|----------|
+| S0 维护页 CRUD `ItemMaster` 等业务表,不写 `mdp_std_*` | **符合**「页面写业务表」 |
+| 把制造真源落在本库 `ProductStructureMaster`,评审却读 `ic_bom` | **不符合**「一层一权威」 |
+| 第三方只进 `mdp_std_item`,S0/评审仍只读 `ItemMaster` | **不符合**闭环(UAT-INT-003) |
+| S0 改 BOM 不进标准层、也不被展开 | **不符合**「中台从权威抽取」 |
+
+`ProductStructureMaster` 是复刻 165/旧执行库的表,不是按新主数据对象设计的。中台方向是旧表当贴源输入,不是继续当新系统唯一写入面。
+
+---
+
+## 4. 现在实际怎么走
+
+```text
+人在 S0 页改
+    → ItemMaster / ProductStructureMaster / CustMaster / …
+    → 评审、开工单读 ItemMaster + ic_bom(不是 ProductStructureMaster)
+    → (无 S0 作业)中台往往没有这份结构
+
+第三方 / 165 入站(复用 S3/S1/S5/S6 实体)
+    → mdp_stg_* → mdp_std_item 等
+    → 看板 / KPI / 诊断 / ChatBI 能显示名称
+    → 不写回 ItemMaster / ProductStructureMaster / ic_bom
+    → S0 页、评审、产品设计带出标准 BOM:看不见
+
+现成 S3_ITEM_ERP
+    → 从本库 ItemMaster 抽到 mdp_stg_item
+    → 这是「本库已有 → 给看板」,不是「第三方 → 灌进 S0」
+```
+
+全仓库 MDP 转换中未检出对 `ItemMaster`、`ProductStructureMaster`、`ic_bom` 的 INSERT/UPDATE。
+
+---
+
+## 5. 对谁有用、对谁没用
+
+| 谁在用 | 读哪 | 只进中台、不回灌业务表 | S0 只改本库、不进中台 |
+|--------|------|------------------------|------------------------|
+| S0 物料 / 标准 BOM / 客户 / 供应商页 | 本库主数据表 | **看不见**第三方新行 | 页上能看见自己改的 |
+| S0 选料、校验「物料必须存在」 | `ItemMaster` | **选不到** | 本库有则可选 |
+| S1 评审 / 开工单 | `ItemMaster` + `ic_bom` | **评不了、展不开** | 改 S0 BOM **仍展不开**(双轨) |
+| S1 产品设计带出标准 BOM | `ProductStructureMaster` | **带不出**中台那棵树 | 能带出本库 S0 树,但不进制造 |
+| 九宫格 / 详情看板 / 诊断 / ChatBI | `mdp_std_*` / `dwd_*` | **可以有名称、维度** | S0 新改的 BOM/物料**不一定**上看板 |
+
+UAT 时下列现象**符合当前实现**,不要当单页缺陷:
+
+- 中台物料同步成功,S0 物料列表没有、评审报物料不存在。  
+- S0 标准 BOM 改了用量,齐套 / 工单明细不变。  
+- 看板已有物料名,S0 页仍空或只有历史行。
+
+---
+
+## 6. 和相邻待办的关系(不要拆成三套解决方案)
+
+| 编号 | 切的面 | 不要重复立项成 |
+|------|--------|----------------|
+| **UAT-INT-003** | 入站:中台 → 未回灌 S0 表 | 「S0 对接没配好」 |
+| **UAT-INT-005** | 产品:目录 BOM vs 真非标 | 「产品设计保存 bug」 |
+| **UAT-INT-006(本篇)** | 架构:S0 整体未进中台闭环(含写出、读入、BOM 双轨) | 「再加一张中台表就好」 |
+| **M-04** | 制造只读 `mdp_std_bom`,消灭 `ic_bom` / S0 双轨 | 与 006 收口方向一致,实施须同批想 |
+
+建议先拍 **真源 + 回灌/改读**,再谈非标快照。真源不统一就上订单 BOM,会变成四轨。
+
+---
+
+## 7. 建议会上拍板的问题
+
+| # | 问题 | 可选方向 |
+|---|------|----------|
+| 1 | 第三方主数据是否必须出现在 S0 维护页? | 否:维持「中台有维、S0 本库手维」(现状);是:必须回灌或改读 |
+| 2 | 制造权威是哪一张? | `ic_bom` / `ProductStructureMaster` / 新建 `mdp_std_bom` 三选一,另外两张降为编辑面或镜像 |
+| 3 | S0 自维(无外部 BOM)时,改完如何进制造和看板? | 发布到权威表 + 贴源;或 S0 直接写权威表再抽取 |
+| 4 | 外部真源时,S0 能否改结构? | 只读镜像 / 变更单回写对方 / 禁止改目录只改订单快照 |
+| 5 | 回灌最小集? | 建议先物料、客户、供应商;标准 BOM 与 M-04 绑在一起 |
+
+未决策前 **不新增 S0 回灌作业、不改 S0 CRUD 去读标准层、不把设计单回写 `ic_bom`、不新增第四条 BOM 存储。**
+
+---
+
+## 8. 若日后收口:方向(非任务书)
+
+跨 S0 / S1 / MDP / 评审,须另开任务书。
+
+1. 指定制造与 S0 页的**唯一权威**(本库一张或标准层一张)。  
+2. 外部入站:贴源 → 标准层 → **upsert 回权威**,或 S0/评审改读标准层(两选一,不要长期双读)。  
+3. S0 自维:权威变更后进入贴源,看板与制造同一份。  
+4. 再谈非标:设计单发布为订单快照(见 UAT-INT-005),不要先加表。
+
+---
+
+## 9. 本文件明确不做
+
+- 不改 S0 页面、MDP 转换、评审展开。  
+- 不把「S0 直写业务表」写成缺陷;问题是闭环和双真源。  
+- 不把本文件当成 S0 已具备独立第三方对接。  
+- 周期规则、KPI 目录、检验规范仍不在主数据同步范围(与 UAT-INT-003 一致)。
+
+---
+
+## 10. 关键代码 / 文档入口
+
+| 行为 | 位置 |
+|------|------|
+| S0 物料 CRUD | `AdoS0MaterialsController` → `ItemMaster` |
+| S0 标准 BOM CRUD | `AdoS0ProductStructuresController` → `ProductStructureMaster` |
+| 评审展开 | `MaterialRequirementCalculator` → `ic_bom` |
+| 物料进标准层(本库抽出) | `S3MdpSyncTransformService` → `mdp_std_item`;源实体 `S3_ITEM_ERP` 读 `ItemMaster` |
+| 产品设计 BOM 只进 S1 贴源 | `S1MdpSyncTransformService` / `S1_PRODUCT_DESIGN_BOM` |
+| 中台分层原则 | [`数据中台方案复盘与未实现清单.md`](./数据中台方案复盘与未实现清单.md);[`S1数据中台迁移实施计划.md`](./数据库迁移/S1/S1数据中台迁移实施计划.md) §硬约束 |
+| 入站未回灌(先登记) | UAT-INT-003;[`Ai-DOP第三方系统集成指南.md`](./系统集成/Ai-DOP第三方系统集成指南.md) §4.1 |
+
+---
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-09-06 | 初版:原则对照、双向断、谁读谁看不见、与 003/005/M-04 关系;登记 UAT-INT-006 |

+ 1 - 1
doc/plan/UAT测试/README.md

@@ -5,7 +5,7 @@
 | 文件 | 用途 |
 |------|------|
 | [`接手速览-旧DOP与165与新系统.md`](./接手速览-旧DOP与165与新系统.md) | **新会话 / 新模型先读这份**:三套系统边界、165 红线、当前架构、UAT-INT-001 接手契约 |
-| [`内部UAT测试待办项.md`](./内部UAT测试待办项.md) | 要排期实现的缺口(`UAT-INT-001` 已实现;`UAT-INT-002`~`UAT-INT-004` 待决策、本轮不编码) |
+| [`内部UAT测试待办项.md`](./内部UAT测试待办项.md) | 要排期实现的缺口(`UAT-INT-001` 已实现;`002`/`003`/`005`/`006` 待决策;`UAT-INT-004` 终态方案已出待确认)。`UAT-INT-004` 见 [第三方标准API推数入站方案](../第三方标准API推数入站方案.md)(五层,非最小 Webhook);`UAT-INT-005` 见 [标准和非标BOM](../当前系统针对标准和非标BOM的适用情况分析.md);`UAT-INT-006` 见 [S0未走数据中台](../S0未走数据中台的问题.md) |
 | [`内部UAT测试问题记录表.md`](./内部UAT测试问题记录表.md) | 已解答、**不改程序**的口径题(库存流水 / `ic_item_stock`) |
 
 客户 UAT 造数、三套租户、验收剧本在 [`../UAT测试数据准备/README.md`](../UAT测试数据准备/README.md)。  

+ 128 - 15
doc/plan/UAT测试/内部UAT测试待办项.md

@@ -5,7 +5,7 @@
 | 项 | 内容 |
 |----|------|
 | 目录用途 | 内部 UAT 过程中发现、需产品/研发跟进的缺口(不是客户 UAT 用例本身) |
-| 登记日期 | 2026-08-27(首条);2026-09-04 增补 `UAT-INT-002`、`UAT-INT-003`、`UAT-INT-004` |
+| 登记日期 | 2026-08-27(首条);2026-09-04 增补 `UAT-INT-002`、`UAT-INT-003`、`UAT-INT-004`;2026-09-06 增补 `UAT-INT-005`、`UAT-INT-006`;2026-09-11 回写 INT-003/004(丙已编码+指南) |
 | 状态 | 见下方「待办索引」 |
 
 ---
@@ -148,16 +148,17 @@ S5「生产领料单」不能改明细,不能当作修复入口。
 | **责任模块** | **S0 运营建模**(主归属);MDP 数据中台为协作通道 |
 | 功能入口 | S0 物料 / 客户 / 供应商 / 员工 / 库位 / 产线 / 标准 BOM 等主数据页(`FUNC-S0-020` 等) |
 | 发现日期 | 2026-09-04 |
-| 优先级 | P2(对接方案已理清;**是否做回灌由负责人决策后再立项**) |
-| 性质 | **能力缺口 / 架构待决**,不是单页 bug |
-| 关联说明 | [`Ai-DOP第三方系统集成指南.md`](../系统集成/Ai-DOP第三方系统集成指南.md);对话结论:S0 无独立 `S0-mdp/refresh` |
+| 优先级 | P2 |
+| 性质 | **部分落地**:方式丙推送 `MDM_*` 后会镜像 S0 五表(server `1.0.538`);S0 **页面只读化未做**(P-046);PULL 通道仍不回灌 |
+| 关联说明 | 入站半环终态见 [`第三方标准API推数入站方案.md`](../第三方标准API推数入站方案.md) **发布平面**;剩余见项目待办 **P-046**。整体闭环见 [`S0未走数据中台的问题.md`](../S0未走数据中台的问题.md) / `UAT-INT-006`。 |
 
 ### 1. 现象与现状(已核实,先不做)
 
 1. S0 页面直接读写本库主数据表:`ItemMaster`、`CustMaster`、`SuppMaster`、`EmployeeMaster`、`LocationMaster`、`LineMaster`、`ProductStructureMaster` 等。  
 2. 现成第三方入站复用 S3/S1/S5/S6 实体(如 `S3_ITEM_*`、`S3_SUPPLIER*`、`S1_CUSTOMER_SQLSERVER`、`S5_LOCATION_*`、`S6_EMPLOYEE_MASTER_SQLSERVER`),只落到 `mdp_stg_*` / `mdp_std_*`,供看板、齐套、诊断消费。  
-3. **没有**「贴源 → upsert 回 S0 业务表」的作业。因此只接通中台时,九宫格/详情看板可以有维表名称,**S0 运营建模页仍可能空或只见本库手工/历史数据**。  
-4. S0 另有大量自有规则(评审/排程周期、优先级、齐套排除、KPI 目录、看板配置、条码规则、检验规范等),**不在本项对接范围**。
+3. **没有**「贴源 → upsert 回 S0 业务表」的作业。因此只接通中台时,九宫格/详情看板可以有维表名称,**S0 运营建模页仍可能空或只见本库手工/历史数据**。评审 / 开工单读 `ItemMaster` + `ic_bom`,同样用不上只在 `mdp_std_item` 里的行。  
+4. S0 另有大量自有规则(评审/排程周期、优先级、齐套排除、KPI 目录、看板配置、条码规则、检验规范等),**不在本项对接范围**。  
+5. **UAT 提示**:中台物料同步成功但 S0 列表没有、评审报「物料不存在」——符合本项,不要当同步失败或 S0 查询 bug。反方向(S0 改了 BOM、计划不变)归 `UAT-INT-005` / `UAT-INT-006`,不是本项漏测。
 
 ### 2. 待决策点(未立项、未编码)
 
@@ -168,7 +169,7 @@ S5「生产领料单」不能改明细,不能当作修复入口。
 | 3 | 回灌对象最小集:建议先物料 / 客户 / 供应商;员工、库位、产线、标准 BOM 是否同期做。 |
 | 4 | 入站方式按对象选 DB 只读或 API(契约见集成指南);租户 / 账套 / 工厂编码如何映射。 |
 
-未决策前 **不新增 S0 回灌作业、不改 S0 CRUD、不改现有 S3/S1 入站落点**。
+终态默认:必须出现在 S0 页(`BUSINESS_MIRROR`,或先改读权威)。**确认入站总方案前不单独开回灌作业。** 禁止「只进 std 对外称已同步」。
 
 ### 3. 跨模块影响(若日后立项须再确认)
 
@@ -187,16 +188,16 @@ S5「生产领料单」不能改明细,不能当作修复入口。
 
 ---
 
-## UAT-INT-004 第三方主动调用我方标准 API 推数入站尚未落地
+## UAT-INT-004 第三方主动调用我方标准 API 推数入站(方式丙)
 
 | 项 | 内容 |
 |----|------|
 | **责任模块** | **数据中台 / 系统集成**(主归属);S1–S7 为消费方 |
-| 功能入口 | 规划:`POST /api/mdp/inbound/{业务对象}`(任务书 P-2);现网无此控制器 |
+| 功能入口 | `POST /api/mdp/inbound/{entityCode}`(`API_INBOUND`,无 `/v1/`) |
 | 发现日期 | 2026-09-04 |
-| 优先级 | P2(对接指南已按「我方拉取」写;**是否补「对方推我」由负责人决策后再立项**) |
-| 性质 | **能力缺口 / 对接方向待决**,不是单页 bug |
-| 关联说明 | [`Ai-DOP第三方系统集成指南.md`](../系统集成/Ai-DOP第三方系统集成指南.md);[`AIDOP双模式全模块对接交付级任务书.md`](../AIDOP双模式全模块对接交付级任务书.md) 任务 P-2;WP0 **D2** 入站以 PULL 为主 |
+| 优先级 | P0(发布收口) |
+| 性质 | **编码已落**(P-2 A–F,server `1.0.538`);对外指南第 4 章已按终态写入。剩余 **P-046**:G1 会签、联调 V10–V14、阶段 H 全量开通 |
+| 关联说明 | [`第三方标准API推数入站方案.md`](../第三方标准API推数入站方案.md);[`Ai-DOP第三方系统集成指南.md`](../系统集成/Ai-DOP第三方系统集成指南.md) **§4 方式丙**;任务书 P-2;身份不修 P-023 |
 
 ### 1. 现象与现状(已核实,先不做)
 
@@ -215,7 +216,7 @@ S5「生产领料单」不能改明细,不能当作修复入口。
 | 3 | 对外契约:路径、鉴权(我方发 Token)、幂等键、按业务对象的报文、限流与回执。 |
 | 4 | 与集成指南、Mock、现场实施说明如何改写,避免第三方误以为已经有接收 API。 |
 
-未决策前 **不新增对外入站控制器、不改现有 PULL/Outbox 行为**。
+终态方案已出并编码(方式丙 / 五层)。路径 **`/api/mdp/inbound/{entityCode}`**(不带 `/v1/`)。对外指南第 4 章已写。禁止无鉴权口;禁止「只进贴源就算对接成功」。剩余见 **P-046**。
 
 ### 3. 跨模块影响(若日后立项须再确认)
 
@@ -234,11 +235,123 @@ S5「生产领料单」不能改明细,不能当作修复入口。
 
 ---
 
+## UAT-INT-005 当前 BOM 按标准目录设计,真非标(一单一结构)不适用
+
+| 项 | 内容 |
+|----|------|
+| **责任模块** | **S0 运营建模**(标准 BOM 主归属);S1 产品设计 / 订单评审为协作;S2 工单展开为消费方 |
+| 功能入口 | S0 → 制造 → 标准 BOM(`FUNC-S0` 标准 BOM 列表);S1 → 产品设计(常规 / 非标);订单评审资源检查 |
+| 发现日期 | 2026-09-06 |
+| 优先级 | P2(产品适配待决;**是否做订单专属 BOM 由负责人决策后再立项**) |
+| 性质 | **架构 / 行业适配待决**,不是单页 bug |
+| 关联说明 | [`当前系统针对标准和非标BOM的适用情况分析.md`](../当前系统针对标准和非标BOM的适用情况分析.md)(待讨论全文);目标方案 [`AIDOP标准与非标双模BOM升级方案.md`](../AIDOP标准与非标双模BOM升级方案.md);项目待办 P-002 / M-04(BOM 双轨)。**S0 与中台双向断:见 [`S0未走数据中台的问题.md`](../S0未走数据中台的问题.md) / `UAT-INT-006`。** |
+
+### 1. 现象与现状(已核实,先不做)
+
+1. 评审、齐套、工单展开读 **`ic_bom` / `ic_bom_child`**:订单行有 BOM 号按号取,否则按物料取 **最新一条**。只对虚拟件(`erp_cls=4`)下钻。  
+2. S0 标准 BOM(`ProductStructureMaster`)可维护用量、启停、生效起止;**生效日期展开不用**。与 `ic_bom` **双轨**,改 S0 不等于改评审结果。  
+3. S1 产品设计有「常规 / 非标」,树上可改制造 BOM,用于图号、设计周期、KPI、S8 图纸看板。选产品会从标准 BOM **带出副本**,保存 **不写回 `ic_bom`**,不参与齐套与工单。  
+4. 工单 `FrozenBOMQty` 只冻单位用量,不冻整棵树。物料必须先在 `ItemMaster`。  
+5. 因此:**标准 / MTO(结构先定稿)合适;轻非标(只要设计跟踪)半套;真非标一单一结构不好用。**
+
+UAT 时下列现象**符合当前设计**,不要单开缺陷单当页面 bug:
+
+- 产品设计单改了 BOM,齐套 / 排程 / 工单明细不变。  
+- 同一物料编码后一单改了目录 BOM,前一单再评吃到新结构。  
+- 设计单标了「非标」,制造仍按目录 BOM 展开。  
+- **S0 标准 BOM 改了用量,齐套 / 工单不变**(S0 写 `ProductStructureMaster`,展开读 `ic_bom`)。与「中台同步了物料、S0 页没有」不是同一面,后者见 `UAT-INT-003` / `UAT-INT-006`。
+
+### 2. 待决策点(未立项、未编码)
+
+| # | 要拍板的事 |
+|---|------------|
+| 1 | 当前客户 / 演示是标准、MTO、轻非标,还是真 ETO?前两类维持现状。 |
+| 2 | 「非标」是否只要求图号 + 设计周期叠交期,不要求订单专属制造 BOM? |
+| 3 | 若要真非标:是否走「订单行绑定已发布制造 BOM 快照」,发布后只增版本? |
+| 4 | 与 M-04(统一 `ic_bom` / S0)是否绑在一起做? |
+
+未决策前 **不新增订单 BOM 表、不把设计单回写 `ic_bom`、不改评审展开口径**。
+
+### 3. 跨模块影响(若日后立项须再确认)
+
+| 波及 | 可能影响 |
+|------|----------|
+| S0 | 标准 BOM 与版本 / 发布策略;临时物料是否允许 |
+| S1 | 产品设计「发布」进制造;评审展开改读订单快照 |
+| S2 / 工单领料 | 冻结整棵树 vs 只冻用量;投产后变更重算 |
+| S3 齐套 / 采购 | 订单专属结构下的缺料与占用 |
+| MDP | 设计 BOM / 标准层是否再加一轨 |
+
+### 4. 明确不做(本项边界)
+
+- 本轮 **先不做** 订单专属 BOM、变更单、设计发布。  
+- 不把 S1「非标产品」解释成已具备 ETO 制造能力。  
+- 不在本项改 UAT 造数剧本或 KPI 口径(场景确认后再说)。
+
+---
+
+## UAT-INT-006 S0 运营建模未走数据中台闭环
+
+| 项 | 内容 |
+|----|------|
+| **责任模块** | **S0 运营建模**(主归属);数据中台为协作;S1 评审 / 工单为消费方 |
+| 功能入口 | S0 物料 / 标准 BOM / 客户 / 供应商等;数据中台同步配置;订单评审 |
+| 发现日期 | 2026-09-06 |
+| 优先级 | P2(架构待决;**真源与回灌/改读由负责人决策后再立项**) |
+| 性质 | **架构 / 中台闭环待决**,不是单页 bug,也不是「S0 直写业务表」本身违规 |
+| 关联说明 | [`S0未走数据中台的问题.md`](../S0未走数据中台的问题.md)(待讨论全文);目标方案 [`AIDOP标准与非标双模BOM升级方案.md`](../AIDOP标准与非标双模BOM升级方案.md) §6;入站单面 **UAT-INT-003**;BOM 行业面 **UAT-INT-005**;M-04 |
+
+### 1. 现象与现状(已核实,先不做)
+
+1. S0 页写本库业务表,**符合**「业务动作写业务表、ETL 不承载副作用」。  
+2. **写出断**:无 `S0-mdp/refresh`,无 `mdp_std_bom`。S0 改的标准 BOM 不进中台,评审也不读 `ProductStructureMaster`(读 `ic_bom`)。  
+3. **读入断**:第三方入站只到 `mdp_stg_*` / `mdp_std_*`,转换不 upsert `ItemMaster` / `ProductStructureMaster` / `ic_bom`。现成 `S3_ITEM_ERP` 是本库物料**抽出去**给看板。  
+4. 因此:中台通了,看板可以有维表名;S0 页和评审仍可能空或只用手工/历史行。
+
+### 2. UAT 提示(符合设计,不要单开缺陷)
+
+| 看到的现象 | 归哪 |
+|------------|------|
+| 中台同步成功,S0 物料/BOM/客户列表没有 | 本项 + `UAT-INT-003` |
+| 评审报物料不存在,看板已有该物料名 | 本项 + `UAT-INT-003` |
+| S0 改了标准 BOM,齐套/工单不变 | 本项 + `UAT-INT-005` |
+| 产品设计单改了 BOM,计划不变 | `UAT-INT-005`(设计单不发布) |
+
+### 3. 待决策点(未立项、未编码)
+
+| # | 要拍板的事 |
+|---|------------|
+| 1 | 第三方主数据是否必须出现在 S0 页,还是「中台有维即可」。 |
+| 2 | 制造权威用 `ic_bom`、`ProductStructureMaster` 还是 `mdp_std_bom`(只留一张)。 |
+| 3 | 入站后回灌业务表,还是 S0/评审改读标准层(两选一)。 |
+| 4 | 与 M-04、非标快照是否同批想,避免先加第四轨。 |
+
+未决策前 **不新增回灌、不改 S0 改读标准层、不新增 BOM 存储。**
+
+### 4. 跨模块影响(若日后立项须再确认)
+
+| 波及 | 可能影响 |
+|------|----------|
+| S0 | 页变为镜像或继续手维 |
+| S1 评审 / 产品设计 | 选料与展开的真源 |
+| MDP | stg→业务表 upsert 或标准层成为权威 |
+| 看板 / 诊断 | 与 S0 页是否同口径 |
+
+### 5. 明确不做(本项边界)
+
+- 本轮 **先不做** 回灌、改读、统一 BOM 存储。  
+- 不把「S0 直写 `ItemMaster`」写成必须立刻改掉的缺陷。  
+- 不在本项做非标订单 BOM(`UAT-INT-005`)或对外推数 API(`UAT-INT-004`)。
+
+---
+
 ## 待办索引
 
 | ID | 标题 | 责任模块 | 状态 |
 |----|------|----------|------|
 | UAT-INT-001 | 工单物料中途变更后,领料单未回写 165 | **S2** | ✅ 已实现(1.0.403) |
 | UAT-INT-002 | 系统机构与业务部门主数据双轨,易被当成两套「采购」 | **S0** | 待产品决策(未编码) |
-| UAT-INT-003 | S0 主数据第三方入站只进中台,未回灌运营建模业务表 | **S0** | 待进一步决策(未编码,本轮不做) |
-| UAT-INT-004 | 第三方主动调用我方标准 API 推数入站尚未落地 | **数据中台** | 待进一步决策(未编码,本轮不做) |
+| UAT-INT-003 | S0 主数据第三方入站只进中台,未回灌运营建模业务表 | **S0** | 🔶 丙通道镜像已接(`1.0.538`);页只读化 / PULL 回灌见 P-046 |
+| UAT-INT-004 | 第三方主动调用我方标准 API 推数入站(方式丙) | **数据中台** | 🔶 编码+指南已落;G1/联调/全量开通见 **P-046** |
+| UAT-INT-005 | 当前 BOM 按标准目录设计,真非标(一单一结构)不适用 | **S0**(S1/S2 协作) | 待讨论 / 待产品决策(未编码,本轮不做) |
+| UAT-INT-006 | S0 运营建模未走数据中台闭环 | **S0**(中台/S1 协作) | 待讨论 / 待产品决策(未编码,本轮不做) |

+ 2 - 0
doc/plan/aidopdev_165库容量与增量写入分析_20260716.md

@@ -173,6 +173,8 @@
 | `mdp_sync_log` / `mdp_transform_run_log` 增加保留清理 | 新增通用日志保留作业,按时间列删除超期行;保留窗口建议 **30–60 天**;如需趋势可清理前先按天聚合入小汇总表 | 封住"只涨不减"的两张运行日志 |
 | `SysLogOp` 降压 | 保留 180 → **60–90 天**;生产评估关闭非必要 DB 日志 / Monitor(`Logging.json`)从源头降日增 | 缓解当前 ~1.8 GB 备份/磁盘压力 |
 | `ado_s8_detection_log` | 若生产开 S8 调度,同步开 `AIDOP_S8_LOG_CLEANUP_ENABLED` | 避免检测日志二次膨胀 |
+| `mdp_inbound_envelope`(P-2 预登记,2026-09-11) | 按 `create_time` 保留 **30 天** 后 delete | 信封可重放窗口有界 |
+| `mdp_inbound_request`(P-2 预登记) | 仅 `COMMITTED`/`FAILED` 且 `update_time` ≥ **7 天**(≥ Idempotency-Key 窗);先删对应 envelope 再删 request;`RECEIVED` 不删 | 幂等窗内可重试,窗后收敛 |
 
 **P1 — 数据中台大表滚动(治本,占库一半以上)**
 

+ 164 - 0
doc/plan/当前系统针对标准和非标BOM的适用情况分析.md

@@ -0,0 +1,164 @@
+# 当前系统针对标准和非标 BOM 的适用情况分析
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **待讨论资料**(产品 / 研发 / 实施对齐用)。先记结论与证据,**不立项、不编码** |
+| 编写日期 | 2026-09-06 |
+| 依据 | 当前仓库实现,不是销售口径 |
+| 内部 UAT | [`UAT测试/内部UAT测试待办项.md`](./UAT测试/内部UAT测试待办项.md) **UAT-INT-005**;S0 与中台闭环见 **UAT-INT-006** / [`S0未走数据中台的问题.md`](./S0未走数据中台的问题.md) |
+| 目标方案 | [`AIDOP标准与非标双模BOM升级方案.md`](./AIDOP标准与非标双模BOM升级方案.md) |
+| 相关已知债 | 项目待办 **P-002 / M-04**:BOM 标准层统一(`ic_bom` 与 `ProductStructureMaster` 双轨) |
+
+---
+
+## 1. 要讨论的问题
+
+现场默认这套系统面向**标准流程行业**:成品先入物料目录,BOM 相对稳定,偶发改用量或替换料。
+
+若面向**非标 / 定制行业**:要生产的产品多数是一单一做,BOM 在设计中甚至投产后还会变。需要判断:
+
+1. 现有模型能不能撑,还是只能撑「轻非标」。
+2. UAT / 演示时,哪些现象是缺陷,哪些是模型边界。
+3. 若要做真非标,最小要补什么(仅方向,本文件不派发实现)。
+
+---
+
+## 2. 结论(建议会上先对齐这句话)
+
+**针对「真非标、一单一结构、BOM 经常变」,当前设计不合适,也不好用。**
+
+系统按「物料目录上的标准结构」工作。评审、齐套、工单展开吃的是 `ic_bom` **最新一条**,不是订单或设计单上的那份树。S1「产品设计」虽有常规 / 非标,能记图号和设计周期,**不能把定制 BOM 发布进制造链路**。
+
+对 **BOM 不常变、产品先入目录再接单** 的标准场景,现有模型是对的。问题不在标准场景做得差,而在把同一套目录假设用到定制厂。
+
+---
+
+## 3. 按场景对号
+
+| 类型 | 适配 | 典型做法 | 现有系统 |
+|------|------|----------|----------|
+| 标准流程 / 备货生产(MTS) | **合适** | 产品目录稳定,BOM 按物料维护 | 目录 BOM + 虚拟件下钻 + 工单冻用量 |
+| 按单生产(MTO),下单前结构已定稿 | **基本可用** | 成品编码已存在,改结构靠改目录再重评 | 无变更单、无影响分析 |
+| 轻非标(同一产品族 + 图号 + 设计周期叠交期) | **半套** | 设计跟踪与制造可分开 | 设计单能跟踪;制造仍吃目录 |
+| 真非标 / ETO(一单一结构,设计中或投产后还改) | **不合适** | 每张订单独立结构,版本可追溯 | 后一单改同一编码会污染前一单;只改设计单则计划不变 |
+
+会上建议先确认客户/演示对象属于上表哪一行,再谈要不要立项。
+
+---
+
+## 4. 现在实际吃的是哪份 BOM
+
+制造只认三条线里的一条:
+
+| 位置 | 作用 | 是否驱动评审 / 齐套 / 工单 |
+|------|------|---------------------------|
+| S0 `ProductStructureMaster` | 标准 BOM 行维护,可填生效起止 | **否**。生效日期计算器不读 |
+| `ic_bom` / `ic_bom_child` | ERP / 中台镜像 | **是**。按订单行 BOM 号,否则按物料取 `Id` 最大的一条;只对虚拟件(`erp_cls=4`)继续下钻 |
+| S1 `ado_product_design_bom` | 产品设计单上的制造 BOM 树 | **否**。服务图纸周期、KPI、S8 图纸看板 |
+
+补充:
+
+- 选产品编码时,设计单会从 `ProductStructureMaster` **带出副本**;保存只写 `ado_product_design_*`,**不回写 `ic_bom`**。
+- 工单 `FrozenBOMQty` 只冻**单位用量**,不冻整棵结构。投产后改目录,在制领料可能对不齐。
+- 物料主数据上的 `BomVersion` / `EnableBomVersion`、工艺流转卡上的版本字段,**展开算法不使用**。
+- 评审 / 开工单要求物料已在 `ItemMaster`。S0 标准 BOM 父子项必须先选已有物料。
+- S0 与 `ic_bom` 双轨(M-04):**改 S0 不等于改评审结果**。
+
+```text
+接单 / 设计
+    │
+    ├─ S1 产品设计(常规/非标)── ado_product_design_bom ──► 图纸周期 / KPI / S8
+    │
+    └─ 订单行.BomNumber 或 物料号
+            │
+            ▼
+        ic_bom(最新一条)──► 评审展开 / 齐套 / 工单明细
+            ▲
+            │  (双轨,未统一)
+        S0 ProductStructureMaster(维护页)
+```
+
+---
+
+## 5. 非标行业要的能力 vs 现有缺口
+
+| 能力 | 现状 | 系统里实际怎样 | 非标需要怎样 |
+|------|------|----------------|--------------|
+| 订单 / 项目专属 BOM | 缺 | 评审只读 `ic_bom` 最新一条 | 每张订单独立结构,互不影响 |
+| 版本 + 变更单(ECN) | 缺 | 就地改行;版本字段计算器不用 | 谁改了什么、影响哪些在制工单 / 采购 |
+| 生效窗真正生效 | 半套 | `StartEff` / `EndEff` 只落库 | 按交期取有效结构 |
+| 设计 BOM → 制造 BOM | 断 | 设计单不写 `ic_bom` | 定稿发布后再驱动评审 / 排程 / 领料 |
+| 未建档临时件 | 缺 | 无 `ItemMaster` 评不了、S0 选不了 | 定制件可先挂描述,后补编码 |
+| 结构冻结(不仅用量) | 半套 | 只冻 `FrozenBOMQty` | 投产冻结整棵树 |
+| 抄近单 / 超级 BOM | 弱 | 选编码可带出标准 BOM | CTO 选项或 ETO 抄近单再改 |
+
+---
+
+## 6. 硬套非标时会出现的现象(UAT 勿当单页缺陷)
+
+下列在当前模型下**符合实现**,不是「设计单保存失败」一类的页面 bug:
+
+1. **把每次定制当成「新物料 + 改目录 BOM」**  
+   能跑通评审,但主数据膨胀;同一编码被后一单改掉,前一单再评会吃到新结构。
+
+2. **只在产品设计单里改 BOM**  
+   设计页看起来像非标工作台,齐套 / 排程 / 工单明细仍展开 `ic_bom`。体感是「设计改了,计划没变」。
+
+3. **投产后还改结构**  
+   没有变更单、没有影响分析、没有按版本重算采购与齐套。旧 DOP 过程 `QtyRequired = CEILING(FrozenBOMQty × Qty)` 只保证数量口径,不保证结构可追溯。
+
+---
+
+## 7. 建议会上拍板的问题
+
+| # | 问题 | 可选方向 |
+|---|------|----------|
+| 1 | 当前客户 / 演示对象是标准、MTO、轻非标,还是真 ETO? | 若前两类:维持现状,UAT 按目录 BOM 验收 |
+| 2 | 「非标」是否只要求图号 + 设计周期叠交期,不要求订单专属制造 BOM? | 是:S1 产品设计已覆盖跟踪;制造发布不做 |
+| 3 | 若要真非标,是否接受「订单行绑定已发布制造 BOM 快照」这条主路径? | 接受后再单独立项;会波及 S0 / S1 / S2 / 工单领料 |
+| 4 | 与 M-04(BOM 标准层统一)是否绑在一起做? | 建议先统一真源,再谈订单快照,避免再加第四轨 |
+
+未决策前 **不新增订单 BOM 表、不把设计单回写 `ic_bom`、不改评审展开口径**。
+
+---
+
+## 8. 若日后做真非标:方向(非任务书)
+
+跨模块,须另开任务书并按协作规则确认后再改。
+
+| 优先级 | 改什么 | 解决什么 |
+|--------|--------|----------|
+| P0 | 订单行绑定一份已发布的制造 BOM 快照(可从设计单发布) | 定制结构进入评审 / 齐套 / 工单,且不改别人的目录 |
+| P0 | 发布后只增版本,禁止就地改已发布树 | 在制与历史订单可复盘 |
+| P1 | 打通设计 BOM → `ic_bom` / 标准层,消灭双轨 | 页面改的就是制造吃的 |
+| P1 | 变更单:列出受影响工单、占用、采购建议 | 投产后改 BOM 可管控 |
+| P2 | 临时物料、抄近单、选项 / 超级 BOM | 降低「先建一堆编码再编结构」的成本 |
+
+---
+
+## 9. 本文件明确不做
+
+- 不改评审、齐套、工单、S0 标准 BOM 页面。
+- 不把 S1 产品设计「非标」解释成已具备 ETO 制造 BOM。
+- 不在本文件展开客户 UAT 用例改写、KPI 口径、M-04 实现细节。
+
+---
+
+## 10. 关键代码入口(便于核对,不是改造清单)
+
+| 行为 | 位置 |
+|------|------|
+| 按 BOM 号或物料取 `ic_bom` 最新一条 | `MaterialRequirementCalculator.ResolveBomIdAsync` |
+| 仅虚拟件下钻 | 同文件 `AppendBomChildAsync`(`erp_cls == 4`) |
+| 设计单覆盖写 BOM / 工艺 | `ProductDesignService.SaveProductDesign` |
+| 选料带出标准 BOM | `ProductDesignService.GetBomAndRouting`(读 `ProductStructureMaster`) |
+| 无 BOM 号仍可开工单 | `OrderWorkOrderGenerationService`(警告,不阻断) |
+| 标准 BOM 生效起止 | `AdoS0ProductStructureMaster.StartEff` / `EndEff`(维护有、计算无) |
+
+---
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-09-06 | 初版:标准 vs 非标适用结论、三轨 BOM、缺口与待决策点;登记 UAT-INT-005 |

+ 176 - 0
doc/plan/第三方标准API推数入站-阶段0字段对表草稿.md

@@ -0,0 +1,176 @@
+# 第三方标准 API 入站 · 阶段 0 字段对表草稿
+
+> 按 [`第三方标准API推数入站执行任务书.md`](./第三方标准API推数入站执行任务书.md) **§1 G1**:阶段 0 未关闭前不得编码;执行者唯一允许动作是起草 3 个实体字段对表,交负责人推进双方会签。
+> 派生自方案 v2 §4.3 / §6 / §10;源键以现网转换 SQL 为准(首期规范字段 = 转换源键,映射恒等)。
+> **状态**:草稿,待业务方对接 + 我方集成负责人会签。对方 ERP 列名列留空,会签时填写。
+
+| 项 | 内容 |
+|----|------|
+| 起草日期 | 2026-09-10 |
+| 会签实体 | `S1_SALES_ORDER_ENTRY`、`MDM_ITEM`、`S5_INVENTORY_TXN` |
+| 公共行契约 | `op`(默认 upsert)、`sourceUpdatedAt`(必填,ISO 8601 **带时区偏移**)、`sourceVersion`(可选,优先于时间戳裁决);`bizKey` 由服务端按 `biz_key_expr` 计算,对方可附带校验 |
+| 首期映射 | `mdp_field_map` 可空跑(恒等);对方列名与规范字段不同时再登记映射 |
+
+会签时每行填:**对方 ERP 字段 / 能否提供 / 样例值 / 备注**。未填不得视为阶段 0 关闭。
+
+---
+
+## 1. `S1_SALES_ORDER_ENTRY`(销售订单行)
+
+| 项 | 值 |
+|----|------|
+| 贴源 | `mdp_stg_so`,`source_table=crm_seorderentry` |
+| 标准层 | `mdp_std_so`(一行一条) |
+| 现网实体码 | `S1_SEORDER_ENTRY`(入站对外码用方案名 `S1_SALES_ORDER_ENTRY`,登记时需与现网码对齐或互为别名) |
+| `biz_key_expr`(现网) | `bill_no` + `:` + `entry_seq`(`S1MdpSyncTransformService` 第 1906 行) |
+| 入站建议键 | 保持现网:`bill_no:entry_seq`(方案示例用 `#`,**会签时定一种,禁止两种并存**) |
+
+### 1.1 字段对表(首期 = 现网 raw 键)
+
+| # | 规范字段(契约 / raw_data 键) | 必/建 | 业务含义 | 落到 | 对方 ERP 字段 | 能否提供 | 样例 | 备注 |
+|---|-------------------------------|:----:|----------|------|---------------|:--------:|------|------|
+| 1 | `bill_no` | 必 | 销售订单号 | `mdp_std_so.order_no` | | | | 业务键 |
+| 2 | `entry_seq` | 必 | 行号 | `mdp_std_so.order_line` | | | | 业务键 |
+| 3 | `seorder_id` | 必 | 头关联(现网 JOIN 头用 `h.Id = e.seorder_id`) | 关联 `crm_seorder` | | | | 只推行、不推头则本键无法 JOIN,见 §1.2 |
+| 4 | `qty` | 必 | 订单数量 | `order_qty` | | | | |
+| 5 | `item_number` | 建 | 物料编码 | `item_code` | | | | |
+| 6 | `item_name` | 建 | 物料名称 | `item_name` | | | | |
+| 7 | `specification` | 建 | 规格 | `item_spec` | | | | |
+| 8 | `unit` | 建 | 单位 | `unit` | | | | |
+| 9 | `map_number` | 建 | 图号 | `map_*` | | | | |
+| 10 | `map_name` | 建 | 图号名 | `map_*` | | | | |
+| 11 | `bom_number` | 建 | BOM | `bom_number` | | | | |
+| 12 | `deliver_notice_count` | 建 | 通知发货数 | `delivered_notice_qty` | | | | |
+| 13 | `deliver_count` | 建 | 实发数 | `delivered_qty` | | | | |
+| 14 | `price` | 建 | 单价 | 同名 | | | | |
+| 15 | `tax_price` | 建 | 含税单价 | 同名 | | | | |
+| 16 | `amount` | 建 | 金额 | 同名 | | | | |
+| 17 | `total_amount` | 建 | 价税合计 | 同名 | | | | |
+| 18 | `plan_date` | 建 | 计划交期 | `plan_delivery_date` | | | | |
+| 19 | `date` | 建 | 行承诺交期 | `promised_delivery_date` | | | | **不是**头上下单日 |
+| 20 | `sys_capacity_date` | 建 | 产能日 | `capacity_date` | | | | |
+| 21 | `sys_material_date` | 建 | 齐套日 | `material_ready_date` | | | | |
+| 22 | `planner_no` | 建 | 计划员工号 | `planner_*` | | | | |
+| 23 | `planner_name` | 建 | 计划员姓名 | `planner_*` | | | | |
+| 24 | `custom_order_bill_no` | 建 | 客户订单号 | `customer_order_no` | | | | |
+| 25 | `progress` | 建 | 进度 | `progress` | | | | |
+| 26 | `urgent` | 建 | 加急 | `urgent` | | | | |
+| 27 | `sourceUpdatedAt` | 必 | 源更新时间(入站契约) | 乱序裁决 | | | | ISO 8601 带 `+08:00` 或 `Z` |
+| 28 | `sourceVersion` | 选 | 源版本号 | 乱序裁决优先 | | | | |
+
+现网转换还从**订单头**读:`date`(下单日)、`rdate`(客户要求日)、`custom_no`/`custom_name`/`country`/`order_type`/`closed`/`flowstate`。九宫格 S1 周期/满足率至少要 **`bill_no, entry_seq, 头.date, 头.rdate, qty`**。
+
+### 1.2 会签必须拍板
+
+| # | 问题 | 建议默认 | 负责人圈选 |
+|---|------|----------|------------|
+| Q1 | 对方是否**同时推** `S1_SALES_ORDER`(头)? | **必须同时推**。现网 `mdp_std_so` 用 `seorder_id` JOIN 头取 `date`/`rdate`;只推行则九宫格算不出评审周期 | 同时推头 / 行上冗余 `order_date`+`rdate`(需改转换,另开任务) |
+| Q2 | 业务键分隔符 | 现网 `:`;方案示例 `#` | `:`(与现网一致) / `#`(改 `biz_key_expr`) |
+| Q3 | 入站 `entity_code` 用方案名还是现网名 | 对外 `S1_SALES_ORDER_ENTRY`,库内可与 `S1_SEORDER_ENTRY` 建别名 | |
+
+---
+
+## 2. `MDM_ITEM`(物料主数据)
+
+| 项 | 值 |
+|----|------|
+| 贴源 | `mdp_stg_item` |
+| 标准层 | `mdp_std_item` → S0 `ItemMaster` 镜像(WP-D,阶段 0 不编码) |
+| 现网实体码 | `S3_ITEM_ERP`(`ItemMaster`)/ `S3_ITEM_NEW`(`ic_item`) |
+| 现网 `biz_key_expr` | ERP:`Domain` + `:` + `ItemNum`;新物料:`tenant_id` + `:` + `number` |
+| 入站建议键 | `ItemNum`(编码全局唯一时)或 `Domain:ItemNum`(与现网 ERP 一致) |
+
+转换 SQL(`S3MdpSyncTransformService`)已兼容两套源键,首期契约取 **ItemMaster 口径** 为规范名,另一套列为别名(`mdp_field_map` 或行内同时接受)。
+
+### 2.1 字段对表
+
+| # | 规范字段 | 现网别名(转换已读) | 必/建 | 落到 | 对方 ERP 字段 | 能否提供 | 样例 | 备注 |
+|---|----------|----------------------|:----:|------|---------------|:--------:|------|------|
+| 1 | `ItemNum` | `number` | 必 | `item_code` | | | | 业务键主字段 |
+| 2 | `Descr` | `name` / `icitem_name` | 必 | `item_name` | | | | |
+| 3 | `Drawing` | `model` | 建 | `model` | | | | 规格/图号 |
+| 4 | `UM` | `unit` | 建 | `unit` | | | | 业务页列为必接,技术页为建;**会签按业务页:必** |
+| 5 | `ItemType` | `item_type` | 建 | `item_type` | | | | |
+| 6 | `Status` | `is_active` | 建 | `status` | | | | 启用/停用 |
+| 7 | `Domain` | — | 建 | 进 bizKey | | | | 多账套时必填 |
+| 8 | `sourceUpdatedAt` | — | 必 | 乱序裁决 | | | | |
+| 9 | `sourceVersion` | — | 选 | 乱序裁决优先 | | | | |
+
+### 2.2 会签必须拍板
+
+| # | 问题 | 建议默认 | 负责人圈选 |
+|---|------|----------|------------|
+| Q4 | 物料编码是否跨工厂唯一 | 唯一则 `bizKey=ItemNum`;否则 `Domain:ItemNum` | |
+| Q5 | 停用如何表达 | `Status=INACTIVE` 或 `is_active=0`;全量快照缺失行留到 WP-D | |
+| Q6 | 本库手建同编码物料 | 不覆盖,进 `mdp_inbound_conflict`(方案 §7.3 / 决策 D-3) | 接受 / 另议 |
+
+---
+
+## 3. `S5_INVENTORY_TXN`(出入库流水,T8 等价)
+
+| 项 | 值 |
+|----|------|
+| 现网落点 | `mdp_stg_t8_kc_tz_head` + `mdp_stg_t8_kc_tz_list` → `mdp_std_t8_kc_*` → `dwd_t8_material_*` |
+| 现网实体 | **两张表两个实体**:`T8_KC_TZ_HEAD` / `T8_KC_TZ_LIST`(`T8BaseInboundMdpSyncService`) |
+| KPI 必用列 | 头:`ztid,lbs,hzyn,zfyn,shyn,shtime`;行:`idid,code,sl`(S5 上线周期:`lbs∈{生产领料,采购入库}` 且 `hzyn=0,zfyn=0,shyn=1`) |
+| 入站建议 | **一行流水 = 头+行拍平**,规范字段 = T8 列名(方案 §7.4「与 T8 源表列同名」) |
+
+`MdpStagingWriter` 一实体只写一张 `target_table_name`。阶段 0 必须选定落表策略(Q7),否则 WP-E 无法登记。
+
+### 3.1 字段对表(拍平行,T8 列名)
+
+| # | 规范字段 | 来自 | 必/建 | 含义 | 对方 ERP 字段 | 能否提供 | 样例 | 备注 |
+|---|----------|------|:----:|------|---------------|:--------:|------|------|
+| 1 | `Id` | 头或行 | 必 | 行主键(建议用行 Id) | | | | 现网 list `biz_key=Id` |
+| 2 | `idid` | 行 | 必 | 关联头 `src_id` | | | | JOIN 键 |
+| 3 | `ztid` | 头 | 必 | 账套 | | | | 现网常用 `pbxfxp` |
+| 4 | `lbs` | 头 | 必 | 业务类型 | | | | 须能区分「采购入库」「生产领料」等 |
+| 5 | `hzyn` | 头 | 必 | 红字 | | | | 0=正单 |
+| 6 | `zfyn` | 头 | 必 | 作废 | | | | 0=有效 |
+| 7 | `shyn` | 头 | 必 | 已审核 | | | | 1=已审 |
+| 8 | `shtime` | 头 | 必 | 审核时间 | | | | 上线周期 D1/D2 |
+| 9 | `date0` | 头 | 建 | 单据日期 | | | | |
+| 10 | `lynoid` | 头/行 | 建 | 来源单号 | | | | |
+| 11 | `code` | 行 | 必 | 物料编码 | | | | |
+| 12 | `sl` | 行 | 必 | 数量 | | | | |
+| 13 | `slzx` | 行 | 建 | 辅助数量 | | | | |
+| 14 | `gdyn` / `gdtime` | 行 | 建 | 过账标记/时间 | | | | |
+| 15 | `rwnoid` | 行 | 建 | 任务/工单号 | | | | |
+| 16 | `jhdate` | 行 | 建 | 计划日 | | | | |
+| 17 | `addtime` | 行 | 建 | 录入时间 | | | | |
+| 18 | `sourceUpdatedAt` | 契约 | 必 | 源更新时间 | | | | |
+| 19 | `sourceVersion` | 契约 | 选 | 源版本 | | | | |
+
+建议 `biz_key_expr`:`CONCAT(IFNULL(idid,''), ':', IFNULL(code,''), ':', IFNULL(Id,''))`(会签确认)。
+
+### 3.2 库存口径可行性(方案 §10 / 评审 P1-12,**必须书面回答**)
+
+| # | 问题 | 对方答复(会签填写) |
+|---|------|----------------------|
+| F1 | ERP 能否推**出入库流水**(本实体)? | 能 / 不能 / 仅部分类型:____ |
+| F2 | 能否推**期初余额**(对应 `S5_INVENTORY_OPENING_BALANCE`)? | 能 / 不能 |
+| F3 | 能否推**结存快照**(对应 `S7_FINISHED_ONHAND` 类)? | 能 / 不能 |
+| F4 | 若无结存:是否同意由「期初 + 流水」在中台自算结存? | 同意 / 不同意 |
+| F5 | `lbs` 取值清单(至少覆盖采购入库、生产领料、成品入库、销售出库) | |
+
+无流水且无期初、又无结存 → **本方案 S5–S7 周转格子不成立**,阶段 0 不得关闭。
+
+### 3.3 会签必须拍板(落表)
+
+| # | 问题 | 建议默认 |
+|---|------|----------|
+| Q7 | 拍平行写入哪张贴源 | **方案 A(建议)**:入站先落一张业务贴源(或复用 list,头字段冗余在行上),WP-E 再拆/投影到 `mdp_std_t8_kc_tz_head/list`,保持现网 KPI SQL 零改。**方案 B**:对方按 T8 拆成两次推送 `T8_KC_TZ_HEAD` + `T8_KC_TZ_LIST`(对方成本高)。**方案 C**:改 S5/S6/S7 KPI 读新拍平表(范围膨胀,不推荐本期) |
+| Q8 | 数量正负与红字 | 与 T8 一致:红字看 `hzyn`,数量 `sl` 保持源符号 |
+
+---
+
+## 4. 会签栏(关闭 G1 用)
+
+| 角色 | 姓名 | 结论 | 日期 |
+|------|------|------|------|
+| 业务方对接 | | 3 表字段可提供情况属实;F1–F5 已答 | |
+| 我方集成负责人 | | 键规则、Q1–Q8 已圈选;同意关闭阶段 0 / 附条件关闭 | |
+
+附条件关闭须写明:哪些字段延期、哪些实体仍禁止开通入站。
+
+**关闭前不得启动 WP-A 编码**(任务书 §1)。关闭后请把结论抄回任务书 §16,并口头/书面确认 §11 决策 D-1 / D-2 / D-3。

+ 675 - 0
doc/plan/第三方标准API推数入站执行任务书.md

@@ -0,0 +1,675 @@
+# 第三方标准 API 推数入站(API_INBOUND)执行任务书
+
+> 派生自 [`第三方标准API推数入站方案.md`](./第三方标准API推数入站方案.md) **v2**(2026-09-10,按[评审](./针对第三方标准API推数入站方案的评审.md)修订,评审文末附作者复核结论)。
+> 本任务书面向**独立执行者**(人或大模型):表结构 DDL、文件清单、类与方法签名、写入规则、验收 SQL/HTTP 用例均已写明。
+> **执行前必读 §10 陷阱与 §11 实施决策**;开工门禁见 §1。
+
+| 项 | 值 |
+|---|---|
+| 编号 | **P-2 执行任务书**(落地 [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) 的「任务 P-2」卡片) |
+| 专题 | 数据中台入站 / 第三方集成(UAT-INT-004;关联 UAT-INT-003/006) |
+| 优先级 | P0(全量推送模式的主入站通道) |
+| 上游文档 | 方案 v2(契约与安全语义的唯一权威);评审 + 作者复核结论 |
+| 影响端 | 后端(`Admin.NET.Plugin.AiDOP`)+ 数据库脚本;**无前端改动**(管理前端页明确不做,见 §13) |
+| 起始版本 | server `1.0.526` |
+| 版本规则 | 每个含后端代码/配置改动的 WP 独立提交、独立 patch +1(§15);纯文档改动不升号 |
+| 状态 | **可执行**(G2 已确认:D-1~D-4 按最优目标态执行,不做临时过渡设计;G1 为发布门禁,不阻塞编码) |
+
+---
+
+## 0. 一句话
+
+在插件侧新建 `POST /api/mdp/inbound/{entityCode}` 统一入站口:插件侧 `InboundSignature` 签名认证(身份复用框架 `SysOpenAccess`,签名串含原始 body 摘要 + `Idempotency-Key`,**Core 零改动**)→ `mdp_inbound_grant` 授权/限流 → 报文校验 + 请求级幂等状态机 → 复用 `MdpStagingWriter` 写既有 `mdp_stg_*` → 事件加急 `ModuleRebuildService.EnqueueAsync` 触发模块重建,五大消费端零改动可用。
+
+---
+
+## 1. 门禁(2026-09-10 负责人决策:编码不设 ERP 会签门禁,契约冻结点移至阶段 G 前)
+
+| # | 门禁 | 判据 |
+|---|------|------|
+| G1 | **发布门禁(阶段 G 前必须关闭;编码不以此门禁)** | 3 个实体(`S1_SALES_ORDER_ENTRY`、`MDM_ITEM`、`S5_INVENTORY_TXN`)真实字段对表双方会签完成;目标客户 ERP「库存流水/期初/结存」推送可行性书面确认完成(方案 §10 阶段 0)。未关闭前契约保持 **v1-draft 草案态**(对外规范字段可经 `mdp_field_map` 映射层调整),不得对外发布指南 |
+| G2 | 负责人已确认本任务书 | 特别是 §11 实施决策 D-1(跨通道过户)、D-2(stale 守卫用 JSON 提取而非加列)、D-3(S0 镜像写入路径) |
+| G3 | 执行环境可用 | `dotnet build server/Admin.NET.sln` 基线通过;本地可连 `aidopdev`(MySQL) |
+
+**编码即刻可动工**:WP-A 起按我方所需先定标准契约(v1-draft)。唯一须在编码期间**并行确认**的事项:**T8 库存口径**(对方能否推库存流水/期初/结存,一句话书面答复即可)——它是数据来源存在性问题,决定 `S5_INVENTORY_TXN` 等实体的契约形态,不能拖到阶段 E;结论回填 §16。若对方无法推流水/期初:该数据来源问题必须在发布门禁(G1)关闭前解决(对方补数或调整契约口径),**本任务书不做降级拉数设计**(2026-09-10 负责人决策:按最优目标态执行,不设临时过渡通道)。
+
+---
+
+## 2. 范围
+
+### 2.1 工作包总览(严格按序,A/B/C 同批发布)
+
+| WP | 内容 | 对应方案阶段 | 提交 |
+|----|------|--------------|------|
+| WP-A | 6 张新表 + `mdp_entity` 扩列(DDL);`InboundSignature` scheme;grant 授权与限流 | 阶段 A | 独立提交 |
+| WP-B | 接收管线:Controller + 报文校验 + 幂等状态机 + 信封归档 + 字段映射 + stale 守卫 + receipts 查询 | 阶段 B | 独立提交 |
+| WP-C | 事件加急接线(实体→模块映射,MDM 触发依赖模块);Rebuild 历史窗口核查(关闭方案 §7.2 待办);INBOUND 源沉默告警 | 阶段 C + §9 | 独立提交 |
+| WP-D | 快照 open/commit 协议 + 差集阈值守卫;主数据回灌 S0 镜像 + 冲突表 | 阶段 D | 独立提交 |
+| WP-E | T8 等价推送实体登记(`S5_INVENTORY_TXN` 等 5 个) | 阶段 E | 独立提交(可并入 WP-D) |
+| WP-F | Mock 联调脚本;digest 对账端点;P-2 卡片更名 `API_INBOUND`;方案 §10 状态回写 | 阶段 F | 独立提交 |
+
+> **A、B、C 各自提交但同批发布上线**(方案 §10:A–C 必须同批上线,禁止先开无鉴权端口)。D/E/F 在 A–C 验收后推进。阶段 G(对外指南)与阶段 H(全量实体开通)**不在本任务书**,另行立项。
+
+### 2.2 本任务书不做的事(摘要,全文见 §13)
+
+不改框架 Core 任何文件;不改 PULL 两个执行器与 `MdpStagingWriter` 的方法签名;不建管理前端页(FUNC 编号留给后续前端任务书);不开放目录 BOM 推送;不修 `api/aidop/out`(P-023);不新建 `mdp_stg_external_row` 贴源模型。
+
+---
+
+## 3. 组件落位总表
+
+### 3.1 新建文件
+
+| # | 文件 | WP | 说明 |
+|---|------|----|------|
+| 1 | `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpInboundEntities.cs` | A | 6 张新表的 SqlSugar 实体(§4 每张表一个类) |
+| 2 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureDefaults.cs` | A | scheme 常量 `InboundSignature` |
+| 3 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureOptions.cs` | A | `AuthenticationSchemeOptions` 子类(时间窗等) |
+| 4 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureHandler.cs` | A | 验签 handler(§5.2) |
+| 5 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundAuthService.cs` | A | grant 校验 + IP 白名单 + 限流(§5.3) |
+| 6 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs` | B | `mdp_field_map` 两层映射(§6.4) |
+| 7 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundReceiveService.cs` | B | 接收主流水线(§6.3) |
+| 8 | `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs` | B | 唯一对外控制器(§6.2) |
+| 9 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundModuleTrigger.cs` | C | 实体→模块映射 + EnqueueAsync 接线(§7.1) |
+| 10 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundSnapshotService.cs` | D | 快照 open/commit/过期/差集守卫(§8.1) |
+| 11 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdmMirrorUpsertService.cs` | D | 主数据 S0 镜像(§8.2) |
+| 12 | `server/Admin.NET.Web.Entry/UpdateScripts/_inbound_wpA.sql`(占位名) | A | 建表 + 扩列(§4);提交时改名 `1.0.<next>.sql` 并配 `.verify.sql` |
+| 13 | `tools/mock/inbound/push_demo.py` | F | 签名 + 推送 + 快照/partial/stale 用例(§9.1) |
+
+### 3.2 改动文件
+
+| 文件 | WP | 改动 |
+|------|----|------|
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs` | A | `ConfigureServices` 内注册 scheme 与各服务(§5.4) |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpEntity.cs` | A | 5 个扩列属性(§4.8) |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs` | C | INBOUND 源沉默告警分支(§7.3) |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs` | D/F | 追加 snapshots/bulk/digest 端点 |
+| `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` | 各 WP | 版本 patch +1 + UpdateScripts Copy 登记 |
+| `doc/plan/AIDOP双模式全模块对接交付级任务书.md` | F | P-2 卡片更名与状态回写(§9.3) |
+
+### 3.3 复用(禁止重写)
+
+`SysOpenAccessService.GetByKey`(取密钥,`IDynamicApiController, ITransient`,可构造注入);框架 `SignatureAuthenticationHandler` 的**时间窗与 nonce 缓存写法**(参照 `server/Admin.NET.Core/SignatureAuth/SignatureAuthenticationHandler.cs`,不继承不改动);`MdpStagingWriter.UpsertAsync`(贴源唯一写入口);`MdpStagingWriter.BuildBizKey`(`internal static`,同程序集可直接调用);`ModuleRebuildService.EnqueueAsync`;`ISysCacheService`(nonce 与限流计数);`AdoS0ReferenceChecker`(WP-D 镜像校验口径)。
+
+---
+
+## 4. 数据库 DDL(WP-A 交付;MySQL;幂等守卫风格)
+
+全部语句须带 `information_schema` 守卫,可重复执行;风格参照 `UpdateScripts/1.0.520.sql`、`1.0.359.sql`。**注意 AutoVersionUpdate 按 `;` 拆句、全程无事务**——禁止存储过程/多句 PREPARE 块,每条 `PREPARE/EXECUTE/DEALLOCATE` 自包含。占位文件名 `_inbound_wpA.sql`,提交那刻改名为 `1.0.<next>.sql` 并配套 `_verify.sql`(校验各表存在、唯一索引列序列、`mdp_entity` 新列存在),同时在 csproj 登记 Copy。
+
+### 4.1 `mdp_inbound_grant`(入站授权)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_inbound_grant (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL COMMENT '凭证绑定租户(冗余自 SysOpenAccess.BindTenantId,便于索引与核对)',
+    access_key VARCHAR(64) NOT NULL COMMENT 'SysOpenAccess.AccessKey;同一对接方可多 key(轮换)',
+    entity_code VARCHAR(100) NOT NULL COMMENT 'mdp_entity.entity_code 白名单',
+    factory_id BIGINT NULL COMMENT '可选绑定工厂;非空时行内 factory_id 必须一致',
+    source_code VARCHAR(50) NOT NULL COMMENT '该对接方×系统的 mdp_source.source_code(source_type=API_INBOUND),贴源 source_system 取此值',
+    rate_limit_per_min INT NOT NULL DEFAULT 60,
+    batch_row_limit INT NOT NULL DEFAULT 500 COMMENT '主数据快照类可放宽到 2000',
+    ip_allowlist VARCHAR(1000) NULL COMMENT '逗号分隔;空=不限制',
+    status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
+    remark VARCHAR(500) NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_grant (access_key, entity_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方推数入站授权';
+```
+
+> `tenant_id` 不进唯一键:AccessKey 本身全局唯一(框架 `SysOpenAccess` 保证),一 key 绑定一租户。
+
+### 4.2 `mdp_inbound_request`(请求级幂等 + 状态机 + 审计)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_inbound_request (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    idempotency_key VARCHAR(128) NOT NULL,
+    request_hash CHAR(64) NOT NULL COMMENT 'sha256(原始请求体字节) hex',
+    sync_batch_id VARCHAR(64) NOT NULL COMMENT 'INB_yyyyMMdd_{id 零填充}',
+    snapshot_id VARCHAR(64) NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT 'RECEIVED/COMMITTED/FAILED',
+    http_status INT NOT NULL DEFAULT 0,
+    result_code INT NOT NULL DEFAULT 0 COMMENT '回执 code:0 ok / 2 partial',
+    accepted INT NOT NULL DEFAULT 0,
+    rejected INT NOT NULL DEFAULT 0,
+    stale_rejected INT NOT NULL DEFAULT 0 COMMENT 'stale 拒绝计数(方案 §7.5)',
+    equal_ts_override INT NOT NULL DEFAULT 0 COMMENT '同时间戳不同内容接受计数',
+    receipt_json MEDIUMTEXT NULL COMMENT '回执快照(replay 原样返回)',
+    error_msg VARCHAR(1000) NULL,
+    client_ip VARCHAR(64) NULL,
+    elapsed_ms INT NOT NULL DEFAULT 0,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_idem (tenant_id, access_key, entity_code, idempotency_key),
+    KEY idx_inbound_req_batch (sync_batch_id),
+    KEY idx_inbound_req_entity_time (entity_code, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方推数入站请求(幂等状态机+审计)';
+```
+
+> 清理窗口 ≥ `Idempotency-Key` 去重窗口(≥7 天),随 P-011 保留策略执行;本任务书不写清理 Job(P-011 已有统一清理入口,把本表登记进去即可,列入 WP-F)。
+
+### 4.3 `mdp_inbound_envelope`(原始信封归档,保留 30 天)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_inbound_envelope (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    request_id BIGINT NOT NULL COMMENT 'mdp_inbound_request.id',
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    path VARCHAR(300) NOT NULL,
+    headers_json TEXT NULL COMMENT '脱敏后头集合;禁止存 X-Signature 与任何密钥材料',
+    body MEDIUMTEXT NOT NULL COMMENT '原始请求体字节(utf8)',
+    body_sha256 CHAR(64) NOT NULL,
+    create_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_envelope_req (request_id),
+    KEY idx_inbound_envelope_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站原始信封归档(重放用)';
+```
+
+### 4.4 `mdp_inbound_snapshot`(全量快照 open/commit 协议,WP-D 使用,WP-A 一并建表)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_inbound_snapshot (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    snapshot_id VARCHAR(64) NOT NULL,
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/COMMITTED/EXPIRED/DIFF_BLOCKED',
+    last_seq INT NOT NULL DEFAULT 0 COMMENT '已接收的最大连续 seq',
+    row_count INT NOT NULL DEFAULT 0,
+    diff_missing INT NULL COMMENT 'commit 时差集缺失行数',
+    diff_ratio DECIMAL(6,4) NULL,
+    opened_at DATETIME NOT NULL,
+    expire_at DATETIME NOT NULL COMMENT '默认 opened_at+24h',
+    committed_at DATETIME NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_snapshot (snapshot_id),
+    KEY idx_inbound_snap_entity (tenant_id, entity_code, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站全量快照批次';
+```
+
+### 4.5 `mdp_inbound_conflict`(主数据镜像键冲突人工裁决,WP-D 使用)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_inbound_conflict (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    entity_code VARCHAR(100) NOT NULL COMMENT 'MDM_ITEM 等',
+    biz_key VARCHAR(300) NOT NULL,
+    source_system VARCHAR(50) NOT NULL,
+    incoming_raw MEDIUMTEXT NOT NULL COMMENT '推送行 raw JSON',
+    mirror_table VARCHAR(64) NOT NULL COMMENT 'ItemMaster/CustMaster/...',
+    conflict_type VARCHAR(40) NOT NULL COMMENT '如 MANUAL_ROW_EXISTS',
+    status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RESOLVED',
+    resolution VARCHAR(500) NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    KEY idx_inbound_conflict (tenant_id, entity_code, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主数据镜像键冲突待裁决';
+```
+
+### 4.6 `mdp_field_map`(对外规范字段 ↔ raw_data 键)
+
+```sql
+CREATE TABLE IF NOT EXISTS mdp_field_map (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    access_key VARCHAR(64) NOT NULL DEFAULT '*' COMMENT '精确对接方优先;*=该实体默认映射',
+    contract_field VARCHAR(200) NOT NULL COMMENT '对外规范字段名',
+    raw_key VARCHAR(200) NOT NULL COMMENT 'raw_data 内键(现有转换 SQL 读取键)',
+    is_required TINYINT NOT NULL DEFAULT 0,
+    status TINYINT NOT NULL DEFAULT 1,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_field_map (tenant_id, entity_code, access_key, contract_field)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站对外契约字段两层映射';
+```
+
+> ⚠️ 与既有 `mdp_field_mapping`(PULL 每实体映射,`MdpEntity.cs` 内 `MdpFieldMapping`)**不是同一张表**,勿改勿混用(§10 陷阱 6)。首期规范字段 = 现有转换源键,映射为恒等,本表可空跑(机制先立)。
+
+### 4.7 `mdp_entity` 扩列
+
+```sql
+-- 逐列 IF-NOT-EXISTS 守卫(风格同 1.0.359.sql 第 28-33 行)
+inbound_enabled      TINYINT NOT NULL DEFAULT 0  COMMENT '入站开通开关'
+inbound_max_rows     INT NOT NULL DEFAULT 500    COMMENT '单请求行数上限(快照类 2000)'
+stale_guard_enabled  TINYINT NOT NULL DEFAULT 1  COMMENT '乱序 stale 守卫;双源期无法比较的实体显式置 0'
+silence_alert_hours  INT NULL                    COMMENT '沉默告警阈值(小时);NULL=不告警'
+silence_calendar     VARCHAR(20) NULL            COMMENT '逗号分隔星期 1-7;空=每天;停产日不告警'
+```
+
+### 4.8 实体类写法
+
+`MdpInboundEntities.cs` 中每表一个类,风格照 `MdpEntity.cs`:`[SugarTable("mdp_inbound_grant", "第三方推数入站授权")]` + 逐属性 `[SugarColumn(ColumnName=...)]`,`Id` 为 `IsPrimaryKey=true, IsIdentity=true`。**这些表不走 CodeFirst InitTables,以 §4 SQL 为唯一建表口径**(与 `mdp_outbox` 等一致)。
+
+---
+
+## 5. WP-A:契约与授权
+
+### 5.1 步骤
+
+| # | 动作 | 产出/判据 |
+|---|------|-----------|
+| 1 | 执行 §4 全部 DDL(占位脚本在本地库先跑通,verify 全绿) | 6 表 + 5 列就位 |
+| 2 | 写 `MdpInboundEntities.cs` 6 个实体类 | 编译通过 |
+| 3 | 写 `InboundSignatureDefaults/Options/Handler` | §5.2 |
+| 4 | 写 `MdpInboundAuthService` | §5.3 |
+| 5 | `Startup.cs` 注册 | §5.4 |
+| 6 | 种子数据(本地/联调库):`SysOpenAccess` 一条(绑租户 + 专用机器用户)+ `mdp_source` 一条(`source_type='API_INBOUND'`)+ `mdp_inbound_grant` 若干 + 测试实体 `inbound_enabled=1` | §12 V1 可跑 |
+| 7 | 跑 §12 V1–V3 安全用例 | 全绿 |
+| 8 | 版本 `1.0.526 → 1.0.527`,脚本改名登记,提交 | §15 |
+
+### 5.2 `InboundSignatureHandler`(验签,定死语义)
+
+继承 `AuthenticationHandler<InboundSignatureOptions>`,构造注入 `SysOpenAccessService`(取密钥)、`ISysCacheService`(nonce 去重)、`ILogger`。处理流程:
+
+1. 读头:`X-Access-Key` / `X-Timestamp` / `X-Nonce` / `X-Signature` / `Idempotency-Key` / `X-Content-SHA256`。缺一 → `AuthenticateResult.Fail`,由控制器侧统一映射(缺 `Idempotency-Key` 属 400,其余属 401;见 §6.2 的 IAuthorizationMiddlewareResultFilter 或 handler 内直接写回——**定死:handler 只 Fail 并带 message,HTTP 语义由控制器入口的中间结果过滤器统一映射**,实现参照下表)。
+
+| 失败项 | HTTP | 回执 message |
+|--------|------|--------------|
+| 缺 `Idempotency-Key` | 400 | `missing Idempotency-Key` |
+| 缺其它签名头 / 未知 AccessKey / 签名不符 / 超时间窗 / nonce 重放 | 401 | 对应原因 |
+| `X-Content-SHA256` 与实际 body 摘要不符 | 400 | `content digest mismatch` |
+
+2. 时间窗:`Math.Abs(now - timestamp) <= 5 min`(与框架 `AllowedDateDrift` 默认一致)。
+3. nonce 去重:缓存键 `AIDOP_INBOUND_NONCE:{accessKey}:{nonce}`,存在即 401;不存在写入、TTL 10 分钟(=时间窗×2,参照框架 `SignatureAuthenticationHandler` 第 98-100 行写法)。
+4. body 摘要:`Request.EnableBuffering()` 后读**原始字节**算 sha256 hex(小写),与 `X-Content-SHA256` 比对;读完 `Request.Body.Position = 0` 供下游重读。**禁止**对 JSON 规范化后再哈希。
+5. 验签:`Base64( HMAC-SHA256(secret, "POST&{path}&{accessKey}&{timestamp}&{nonce}&{contentSha256}&{idempotencyKey}") )`。
+   - `{path}` = `Request.Path.Value`(含前导 `/`,不含 query string),如 `/api/mdp/inbound/S1_SALES_ORDER`;**契约文档与 Mock 必须逐字一致**。
+   - secret 取自 `SysOpenAccessService.GetByKey(accessKey)` 返回的 `AccessSecret`(字段名以 `SysOpenAccess` 实体为准)。
+6. 通过后构造 Claims:`TenantId`(=`BindTenantId`)、`UserId`(=`BindUserId`)、自定义 `access_key`;`AuthenticationTicket` 的 scheme 用 `InboundSignatureDefaults.AuthenticationScheme`。
+
+### 5.3 `MdpInboundAuthService`(授权 + 限流)
+
+```csharp
+public sealed class MdpInboundAuthService : ITransient
+{
+    // 返回 null = 通过;否则返回 (httpStatus, message)
+    Task<(int Status, string Message)?> CheckAsync(
+        string accessKey, long tenantId, string entityCode, string? clientIp, CancellationToken ct);
+}
+```
+
+- 查 `mdp_inbound_grant`:`(access_key, entity_code)` 命中且 `status=1`;未命中 → 403(实体未开通/未授权)。实体 `mdp_entity.inbound_enabled=0` 或不存在 → 403(与授权同口径,对外不区分,防探测)。
+- `ip_allowlist` 非空:客户端 IP(`HttpContext.Connection.RemoteIpAddress`,受反代时取 `X-Forwarded-For` 首段)须命中,否则 403。
+- 限流:`ISysCacheService` 计数键 `AIDOP_INBOUND_RATE:{accessKey}:{entityCode}:{yyyyMMddHHmm}`,INCR 语义(无 INCR 则 get+set 1 分钟 TTL),超 `rate_limit_per_min` → 429。**进程级/缓存级精度均可接受**,多实例各计各的属已知近似(§10 陷阱 9)。
+- 行数上限由接收服务按 `Math.Min(grant.batch_row_limit, entity.inbound_max_rows)` 判定(§6.3)。
+
+### 5.4 `Startup.cs` 注册(`ConfigureServices` 末尾追加)
+
+```csharp
+// API_INBOUND 入站:插件侧独立签名 scheme(严禁传入默认 scheme 参数,全局默认仍是 JWT)
+services.AddAuthentication()
+    .AddScheme<InboundSignatureOptions, InboundSignatureHandler>(
+        InboundSignatureDefaults.AuthenticationScheme, _ => { });
+```
+
+`MdpInboundAuthService` / `MdpInboundReceiveService` 等实现 `ITransient` 即可自动注册(本仓 `MdpStagingWriter : ITransient` 同款);接口型服务参照 Startup 既有注释「不依赖 ITransient 自动按接口注册」,显式 `AddTransient`。
+
+---
+
+## 6. WP-B:接收管线(核心)
+
+### 6.1 步骤
+
+| # | 动作 | 产出/判据 |
+|---|------|-----------|
+| 1 | 写 `MdpInboundController`(§6.2)与回执 DTO | 编译通过 |
+| 2 | 写 `MdpInboundFieldMapper`(§6.4) | 单测级自验 |
+| 3 | 写 `MdpInboundReceiveService.ReceiveAsync`(§6.3) | 全流程 |
+| 4 | 跑 §12 V4–V8 | 全绿 |
+| 5 | 版本 patch +1,提交 | §15 |
+
+### 6.2 `MdpInboundController`
+
+```csharp
+[ApiController]
+[Route("api/mdp/inbound")]
+[NonUnify]   // 回执手工构造 code/message/data;HTTP 状态码语义必须精确(方案 §4.4)
+[Authorize(AuthenticationSchemes = InboundSignatureDefaults.AuthenticationScheme)]
+public class MdpInboundController : ControllerBase
+{
+    [HttpPost("{entityCode}")]                                   // 接收数据(本 WP)
+    [HttpGet("{entityCode}/schema")]                             // 对外规范字段 JSON Schema(本 WP,从 mdp_field_map + mdp_entity 生成;无映射行时按恒等输出实体现有转换源键清单)
+    [HttpGet("receipts/{syncBatchId}")]                          // 按批次查回执(本 WP)
+    [HttpPost("{entityCode}/snapshots")]                         // WP-D
+    [HttpPost("{entityCode}/snapshots/{snapshotId}/commit")]     // WP-D
+    [HttpPost("{entityCode}/bulk")]                              // WP-D(NDJSON 分块,每块独立幂等键)
+    [HttpGet("{entityCode}/digest")]                             // WP-F
+}
+```
+
+- 类级 `[Authorize(AuthenticationSchemes=...)]` **唯一指定**入站 scheme;**严禁 `[AllowAnonymous]`**;严禁把本控制器动作加进 `AidopKanbanController`。
+- `{entityCode}` 路由参数大小写不敏感匹配 `mdp_entity.entity_code`(查询时 `ToUpper` 归一)。
+- 401/400 的精确语义由 §5.2 表执行;`IAuthorizationMiddlewareResultFilter` 或handler 内 `Response` 直写均可,**选定一种后全控制器一致**。
+
+### 6.3 `MdpInboundReceiveService.ReceiveAsync` 主流水线(定死顺序,勿换序)
+
+入参:`(entityCode, rawBodyBytes, claims(accessKey/tenantId), clientIp, snapshotId?, ct)`。
+
+1. **实体解析**:查 `mdp_entity`(`entity_code` + `inbound_enabled=1`),不存在 → 403。取 `TargetTableName`、`BizKeyExpr`、`inbound_max_rows`。
+2. **授权与限流**:调 `MdpInboundAuthService.CheckAsync`(§5.3)。
+3. **报文形态**:UTF-8 JSON;接受 `data.list` 数组或根数组(与 PULL 一致)。解析用 `JsonDocument` 手工枚举对象属性——**同一行内出现仅大小写不同的重复键 → 整批 400**(`duplicate key`)。行数 > `Math.Min(grant.batch_row_limit, entity.inbound_max_rows)` → 413;单行序列化 > 64KB → 413(整批拒)。
+4. **信封归档**:先插 `mdp_inbound_envelope`(headers 脱敏:剔除 `X-Signature`、`Authorization`、`Cookie`)。归档失败不阻断主流程,记 Error 日志。
+5. **请求幂等状态机**(`mdp_inbound_request`,`sync_batch_id = $"INB_{DateTime.Now:yyyyMMdd}_{requestId 生成后回填}"`):
+   1. `INSERT`(status=`RECEIVED`);撞 `uk_inbound_idem` 唯一键 → 查已有行:
+      - `COMMITTED` 且 `request_hash` 相同 → 返回其 `receipt_json`,HTTP 202,`idempotentReplay=true`;
+      - `COMMITTED` 且哈希不同 → 409;
+      - `RECEIVED` 且 `update_time` 在 10 分钟内 → 409 `in-progress`;
+      - `FAILED`,或 `RECEIVED` 超 10 分钟(崩溃残留)→ `UPDATE ... SET status='RECEIVED', update_time=NOW() WHERE id=@id AND status=@old`,**影响行数=1 才继续**,否则 409 `in-progress`。
+   2. 拿到 `id` 后回填 `sync_batch_id`(`INB_yyyyMMdd_{id:D6}`)。
+6. **行级校验与映射**(逐行,错误进 `rejectedRows` 不中断):
+   1. 字段映射:`MdpInboundFieldMapper`(§6.4)把对外规范字段名改写成 raw 键,输出行字典 + 该行原始 JSON(`JsonElement.GetRawText()`,改写只影响列取值,不动 raw_data 原文——**raw_data 存映射后的键名**,转换 SQL 才读得到;对方原文在信封里)。
+   2. `sourceUpdatedAt` 必填且必须 ISO 8601 **带时区偏移**(`DateTimeOffset.TryParse` 且原文含 `+hh:mm`/`Z`),否则行级拒绝;`sourceVersion` 可选,须可转 long。
+   3. `tenant_id`:行内若出现且 ≠ 凭证绑定租户 → **整批 403**(不是行级拒绝);通过后在行字典注入 `tenant_id = 绑定租户`(供 Writer 租户解析)。
+   4. 工厂:grant 绑工厂时行内 `factory_id` 必须一致,不一致行级拒绝;未绑定时保留行内值,Writer 按既有口径归一化。
+   5. `bizKey`:**服务端计算**——`MdpStagingWriter.BuildBizKey(entity.BizKeyExpr, rowDict)`;为 null → 行级拒绝(`missing biz key fields`)。对方附带的 `bizKey` 与计算值不一致 → 行级拒绝(`bizKey mismatch`)。
+   6. `op`:默认 `upsert`;`delete` 时行字典写入软删标记键(`is_deleted=1`,由转换层过滤——**先核查目标 stg 的转换 SQL 是否过滤该键,不过滤的实体在 `mdp_entity.remark` 标注并拒绝 `op=delete`**,见 §10 陷阱 11)。
+7. **stale 守卫**(`stale_guard_enabled=1` 时,三分支,方案 §7.5;实施决策 D-2):对每行先查既有贴源行的裁决依据——
+   `SELECT source_biz_key, JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sourceUpdatedAt')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sourceVersion')) FROM {table} WHERE tenant_id=@t AND source_table=@st AND source_biz_key=@bk LIMIT 1`
+   - 既有行无裁决依据(PULL 落库老数据)→ 放行(不比较);
+   - 新行依据 > 既有 → 放行;
+   - 新行依据 < 既有 → 行级拒绝 `reason=stale`,`stale_rejected++`;
+   - 相等 → 比内容哈希(新行 raw JSON vs 既有 `raw_data`,规范化比较**仅限字符串级精确相等**,不等 → 放行并 `equal_ts_override++`)。
+   - `sourceVersion` 双方都有时优先于时间戳比较。
+8. **跨通道过户(实施决策 D-1)**:第 7 步查询命中且既有行 `source_system ≠ 本 grant.source_code` → 先执行 `UPDATE {table} SET source_system=@sys, source_row_id=@bk WHERE id=@id`(把 PULL 时代行过户给入站源),再走第 9 步。同批多行同事务内逐行执行。
+9. **写贴源 + 置 COMMITTED(同一事务,必须)**:
+   ```csharp
+   var tran = await _db.Ado.UseTranAsync(async () => {
+       foreach (var row in acceptedRows)
+           await _writer.UpsertAsync(source, entity, sourceTable, row.Dict, row.RawJson, row.BizKey, ctx);
+       // UPDATE mdp_inbound_request SET status='COMMITTED', receipt_json=..., http_status=202, ...
+   });
+   if (!tran.IsSuccess) { /* UPDATE status='FAILED', error_msg;返回 500 */ }
+   ```
+   - 参数定死:`source` = 按 `grant.source_code` 查出的 `MdpSource` 行(无则 500 配置错误);`sourceTable` = `entity.SourceTableName ?? entity.EntityCode`;`sourceRowId` = 行 `bizKey`;`ctx` = `new MdpPullContext { TenantId = 绑定租户, BatchId = syncBatchId }`。
+   - **`MdpStagingWriter.UpsertAsync` 返回 0**(租户/工厂解析失败被跳过)→ 该行计入 `rejectedRows`(`reason=tenant_unresolved`),**不得计入 accepted**。
+   - ⚠️ `UseTranAsync` 不抛异常,**必须判 `tran.IsSuccess`**(§10 陷阱 1)。
+10. **回执**:HTTP 202;`code` = 全收 `0`、有拒绝行 `2`(partial);`data` 含 `entityCode/accepted/rejected/rejectedRows[]/idempotentReplay=false/syncBatchId/requestId/transformEnqueued`。`rejectedRows` 每项 `{bizKey, reason}`(stale 的也进)。
+11. **事件加急**:WP-C 接线后此处调 `MdpInboundModuleTrigger.EnqueueForEntity(...)`;WP-B 阶段先置 `transformEnqueued=false`(A–C 同批发布,对外不可见中间态)。
+12. **异常兜底**:第 6–9 步任何未捕获异常 → `UPDATE status='FAILED', error_msg=...`(独立更新,不在事务里),HTTP 500,`code=500`。
+
+### 6.4 `MdpInboundFieldMapper`
+
+- 加载 `(tenant_id, entity_code)` 的映射行:`access_key = 精确 key` 优先,`*` 兜底;两者合并(精确覆盖默认同名行)。
+- 无映射行 → **恒等映射**(首期规范字段=现有转换源键,成本为零)。
+- 行内出现映射表之外的字段 → 原样保留(向前兼容),不因未知字段拒绝。
+- 键比较大小写不敏感;输出 raw 键名以映射表 `raw_key` 的大小写为准。
+
+### 6.5 `receipts/{syncBatchId}`
+
+按 `sync_batch_id` 查 `mdp_inbound_request`(限定本 access_key),返回 `status/accepted/rejected/stale_rejected/receipt_json`。非本 key 的批次 → 404(不泄露存在性)。
+
+---
+
+## 7. WP-C:事件加急、历史窗口核查与沉默告警
+
+### 7.1 实体 → 模块映射(`MdpInboundModuleTrigger`,显式静态表,勿做「约定优于配置」)
+
+```csharp
+// 方案 §7.7:MDM 不属任何单一模块,推送成功后触发所有依赖模块
+private static readonly IReadOnlyDictionary<string, string[]> EntityModuleMap =
+    new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
+{
+    ["MDM_ITEM"]                = ["S1","S2","S3","S5","S6"],
+    ["MDM_CUSTOMER"]            = ["S1"],
+    ["MDM_SUPPLIER"]            = ["S3","S4","S5"],
+    ["MDM_SOURCE_LIST"]         = ["S3"],
+    ["MDM_LOCATION"]            = ["S5","S7"],
+    ["MDM_EMPLOYEE_HEADCOUNT"]  = ["S9"],   // 无 S9 handler 时跳过并记 Info
+    // S1–S7 单据实体 → 各自模块(前缀法可取,但必须以上表存在性校验兜底:未登记的实体→记 Warning 不阻断接收)
+};
+```
+
+- 接线点:`MdpInboundReceiveService` 第 11 步。`EnqueueAsync(moduleCode, tenantId, factoryId: 0, requestedBy: null, triggerType: "INBOUND", ct)`;返回 202 记 `transformEnqueued=true`,409(同 scope 在跑)为正常去抖、记 Info,**不视为失败**。
+- 触发失败(异常)不回滚贴源(贴源已 COMMITTED),记 Error 并依赖 60 分钟定时 Job 兜底——回执照常 202,`transformEnqueued=false`。
+
+### 7.2 关闭方案 §7.2 遗留待办(Rebuild 历史 biz_date 窗口核查)
+
+只读核查,不改代码:
+
+1. 通读 `DataPlatform/MdpRebuild/`(`ModuleRebuildWorker`、`S1ModuleRebuildHandler` 等)与各 `*MdpSyncTransformService` 的窗口参数:确认重建是「整模块全量」还是「支持指定 biz_date 窗口」。
+2. 结论回填:在本任务书 §16 执行记录写明结论;若**不支持**任意历史窗口,则在方案 §7.2 待办旁标注「已核查:不支持,补推历史数据需先改造转换层」并把该改造列入 §13 不做的事(另开任务书)。**不允许核查无结论就关闭 WP-C**。
+
+### 7.3 INBOUND 源沉默告警(扩展 `MdpSourceHealthCheckJob`)
+
+- 现状:该 Job 探活 `mdp_source`(DB `SELECT 1` / API health)。
+- 扩展:对 `mdp_entity.inbound_enabled=1 AND silence_alert_hours IS NOT NULL` 的实体,查 `mdp_inbound_request` 该实体最近一条 `status='COMMITTED'` 的 `create_time`;超过阈值**且当天命中 `silence_calendar`**(空=每天)→ 写健康告警(沿用该 Job 既有写回字段/日志通道)。
+- 从未收到过任何推送的实体:以 `inbound_enabled` 打开时刻为基线,同样适用阈值判定。
+
+### 7.4 验收与提交
+
+§12 V9(事件加急分钟级可见)+ V13(沉默告警构造)。版本 patch +1,提交。
+
+---
+
+## 8. WP-D:快照协议与主数据回灌
+
+### 8.1 快照 open/commit(`MdpInboundSnapshotService` + 控制器两端点)
+
+- **open**:`POST {entityCode}/snapshots` → 生成 `snapshot_id`(`SNAP_{yyyyMMddHHmmss}_{随机8位}`),插 `mdp_inbound_snapshot`(OPEN,`expire_at = now + 24h`)。同一 `(tenant_id, access_key, entity_code)` 已有 OPEN 快照 → 409(先 commit 或等过期)。
+- **数据请求带快照**:`data.snapshotId` + `data.seq` 经 `ReceiveAsync` 透传:快照须 OPEN 且未过期(过期 → 400 `snapshot expired`,并惰性置 `EXPIRED`);`seq` 必须 = `last_seq + 1`,缺口 → 400 `seq gap`(告警日志);通过则 `last_seq`、`row_count` 随请求事务更新。快照请求同样走 §6.3 完整流水线(幂等/映射/守卫不变)。
+- **commit**:`POST {entityCode}/snapshots/{snapshotId}/commit` →
+  1. OPEN 且未过期才受理,否则 400/409;
+  2. 差集:查本实体贴源表中 `source_system = 本 grant.source_code` 且**未出现于本快照行集**的 `source_biz_key`;快照行集 = 本快照各请求 `mdp_inbound_request.sync_batch_id` 对应的 stg 行(按 `sync_batch_id IN (...)` 查)。
+  3. `diff_ratio = diff_missing / 该源该实体贴源总行数`;> 20%(或实体级配置)→ 状态置 `DIFF_BLOCKED`,**不执行任何停用**,记告警,回 409 `diff threshold exceeded`;人工核对后由运维在库上将状态改回 `OPEN` 并带 `?force=true` 重新 commit(`force` 仅当状态 `DIFF_BLOCKED` 时可用,且要求调用方 grant 具备该实体权限 + 记审计)。
+  4. ≤ 阈值 → 同事务:缺失行打软删/停用标记(口径同 §6.3 第 6.6 步 `is_deleted` 标记)+ 快照置 `COMMITTED`。
+- **过期**:惰性判定(写入/commit 时检查 `expire_at`)+ `MdpSourceHealthCheckJob` 周期顺带把 `OPEN 且 expire_at < now` 的置 `EXPIRED`。未 commit 的快照**不产生任何删除语义**。
+
+### 8.2 主数据回灌 S0 镜像(`MdmMirrorUpsertService`;实施决策 D-3 须先确认)
+
+**前置核查(WP-D 动工第一天完成,结果写 §16)**:
+
+1. 读 `Entity/S0/Sales/AdoS0ItemMaster.cs`、`AdoS0CustMaster.cs`、`Entity/S0/Supply/AdoS0SuppMaster.cs`、`Entity/S0/Warehouse/AdoS0LocationMaster.cs`、`AdoS0EmployeeMaster.cs`,确认五表是否已有来源标识列(`source_system` / 同步时间列)。**无则在 WP-D 的 SQL 脚本增列** `source_system VARCHAR(50) NULL`、`source_updated_at DATETIME NULL`(幂等守卫)。
+2. 读 `AdoS0ReferenceChecker` 与 S0 通用写入路径(`Controllers/S0/`),列出每类主数据「必须走的校验/审计/缓存失效/字段共管保护」清单。
+
+**镜像 upsert 规则**(`MDM_ITEM/CUSTOMER/SUPPLIER/LOCATION/EMPLOYEE_HEADCOUNT` 五实体,逐实体一个镜像方法):
+
+- 触发时机:对应 stg 批 COMMITTED 后、模块重建入队前调用(同事务外、失败不阻断接收,记 Error 依赖下轮重建补偿)。
+- 写入:经 S0 实体类 + SqlSugar 仓储 upsert;**必经** `AdoS0ReferenceChecker` 同口径校验与 WP9 字段共管口径(只写推送方负责的字段,WMS/165 共管字段跳过);行写 `source_system = grant.source_code`、`source_updated_at = sourceUpdatedAt`。
+- 冲突:目标表已存在同编码且 `source_system` 为空/NULL(本库手建行)→ **不覆盖**,插 `mdp_inbound_conflict`(`conflict_type='MANUAL_ROW_EXISTS'`,PENDING),人工裁决后运维改 `RESOLVED` 并补 `resolution`;裁决为「以推送为准」的行由运维 SQL 手工过户 `source_system` 后,下轮推送自动接管。
+- 乱序:镜像行 `source_updated_at` 新于推送行 → 跳过并记 `skipped_stale`(与 stg 守卫同语义)。
+
+### 8.3 验收与提交
+
+§12 V10(快照全流程)+ V11(镜像 + 冲突)。版本 patch +1,提交。
+
+---
+
+## 9. WP-E / WP-F
+
+### 9.1 WP-F Mock 脚本(`tools/mock/inbound/push_demo.py`)
+
+- Python 3 标准库实现:HMAC-SHA256 签名(签名串逐字节按 §5.2 拼)、`X-Content-SHA256` 原始字节哈希、时间戳/nonce/`Idempotency-Key` 生成。
+- 内置用例(对应 §12):正常推送、缺 `Idempotency-Key`、改一字节 body、过期时间戳、重放 nonce、同 Key 重放、同 Key 不同体、partial(混入缺字段行)、stale(旧 `sourceUpdatedAt`)、快照 open→两批→commit。
+- 配置走命令行参数(base-url/access-key/secret/entity),**密钥不落库不落 git**,脚本内禁止写死真实 secret。
+
+### 9.2 WP-E T8 等价实体登记
+
+向 `mdp_entity` 插入 5 行(幂等 `INSERT ... WHERE NOT EXISTS` 守卫,并入 WP-E SQL 脚本):`S5_INVENTORY_TXN`、`S5_INVENTORY_OPENING_BALANCE`、`S7_FINISHED_ONHAND`、`S7_FINISHED_OPENING_BALANCE`、`MDM_EMPLOYEE_HEADCOUNT`;`target_table_name` 指向既有 `mdp_stg_t8_*` 对应表(**先读 `T8BaseInboundMdpSyncService` 确认真实表名再落**),`biz_key_expr` 按阶段 0 会签口径,`inbound_enabled=1`。登记后跑通 §12 V12(无 T8 现场口径抽验)。
+
+### 9.3 命名与文档回写(WP-F 末尾)
+
+- `AIDOP双模式全模块对接交付级任务书.md` 任务 P-2 卡片:状态回写为「已落地(API_INBOUND)」,路径更正为 `/api/mdp/inbound/{entityCode}`(方案 v2 §4.1:首版隐含 v1,不带 `/v1/`)。
+- 方案 §10 阶段表:A–F 标记完成日期;§7.2 待办按 §7.2 核查结论关闭或转列。
+- P-011 清理入口登记 `mdp_inbound_envelope`(30 天)与 `mdp_inbound_request`(≥7 天,与 Idempotency-Key 窗口一致)。
+- **不碰** `系统集成/Ai-DOP第三方系统集成指南.md`(阶段 G 另立项,方案 §10 明确)。
+
+---
+
+## 10. 陷阱(执行前必读)
+
+1. **`UseTranAsync` 不抛异常**。本仓 SqlSugar 的 `UseTranAsync` 捕获委托内异常返回 `IsSuccess/ErrorException`(先例 `KpiDimensionRunService.cs:88-103`)。§6.3 第 9 步必须判 `tran.IsSuccess`,写 `try/catch` 无效。
+2. **scheme 注册严禁带默认 scheme 参数**。`services.AddAuthentication()` 无参调用;传了默认 scheme 会把全局 JWT 策略顶掉,全站登录态崩。
+3. **禁 `[AllowAnonymous]`**;全局 `enableGlobalAuthorize: true` 下,入站控制器只靠类级 `[Authorize(AuthenticationSchemes = "InboundSignature")]` 生效,不要再加 `[Authorize]` 无参属性(会叠加 JWT 默认策略导致 401)。
+4. **body 只能读原始字节**:`Request.EnableBuffering()` 后读流算哈希,读完 `Position=0`;禁止「反序列化再序列化」回算哈希。
+5. **`MdpStagingWriter` 是贴源唯一写入口**,禁止在 Receive/Snapshot/Mirror 里另写 `INSERT INTO mdp_stg_*`;`BuildBizKey` 是 `internal static` 同程序集直接用,禁止复制实现。
+6. **`mdp_field_map` ≠ `mdp_field_mapping`**。后者是 PULL 每实体映射(`MdpFieldMapping`),本任务书不读不写它。
+7. **Writer 返回 0 ≠ 成功**:租户/工厂解析失败会静默跳行(`MdpStagingWriter.cs` 第 57-67 行),必须按 §6.3 第 9 步计入 rejected。
+8. **`[NonUnify]` 下回执手工构造** `code/message/data`;HTTP 状态码与 `code` 的对应关系逐条按方案 §4.4 表,特别是 partial 时 HTTP 202 + `code=2`。
+9. 限流与 nonce 用 `ISysCacheService`(进程/缓存级),**多实例各计各的属已知近似**,不引入 Redis 新依赖、不改框架 RateLimit 配置。
+10. 信封归档 `headers_json` 必须脱敏:剔除 `X-Signature`、`Authorization`、`Cookie`;body 原文照存。
+11. `op=delete` 落地前**先核查**目标实体转换 SQL 是否过滤 `is_deleted`;不过滤的实体拒绝 `op=delete` 并在 `mdp_entity.remark` 标注,待转换层补齐后开放(方案 §7.6「补齐透传」另行排期)。
+12. SQL 脚本:**AutoVersionUpdate 按 `;` 拆句、全程无事务**(`Update/AutoVersionUpdate.cs`),禁止存储过程与跨句变量依赖;每条 `PREPARE/EXECUTE/DEALLOCATE` 自包含;占位名提交时改 `1.0.<next>.sql` 并登记 csproj Copy(先例 `1.0.359.sql` 头注释)。
+13. stg 表两类唯一键并存:`mdp_stg_so` 型为 `(tenant_id, source_system, source_table, source_biz_key)`(1.0.520 后),其余多为 `(tenant_id, source_table, source_row_id)`——跨通道过户(D-1)对两类都适用,勿假设单一形态。
+14. digest 为日终低频接口,允许扫 `mdp_inbound_request` + stg 按 `sync_batch_id`,首期不加新索引。
+
+## 11. 实施决策(本任务书定死;不认可则停工上报,勿自行改向)
+
+| # | 决策 | 理由与边界 |
+|---|------|------------|
+| D-1 | **跨通道过户**:入站行命中其它 `source_system` 的同 bizKey 贴源行时,先 `UPDATE` 过户(`source_system`+`source_row_id` 改写成入站口径)再走 Writer upsert,实现方案验收 #4「先 PULL 后 INBOUND → 一行」。**反向(INBOUND 后 PULL 再跑)不保证单行**——故切换为 INBOUND 即必须停用该实体 PULL 任务(阶段 H 检查单强制项),不做双源并存设计(2026-09-10 负责人决策) | stg 唯一键两类形态(陷阱 13)下,裸 upsert 无法跨源归并;过户 UPDATE 只动元数据键,业务字段仍只经 Writer 写 |
+| D-2 | **stale 守卫用 `JSON_EXTRACT(raw_data,'$.sourceUpdatedAt'/'$.sourceVersion')` 提取既有行裁决依据**,不给 `mdp_stg_*` 加专用列 | 方案 §7.5 的「PULL 补写 source_updated_at 列」不在本期(§13);INBOUND 契约已强制行内带该两键,JSON 提取零 DDL 即可裁决;PULL 老数据无键 → 放行不比较,双源期无法裁决的实体 `stale_guard_enabled=0` 显式标注 |
+| D-3 | **S0 镜像写入路径**:`MdmMirrorUpsertService` 经 S0 实体类 + SqlSugar 仓储 upsert,必经 `AdoS0ReferenceChecker` 同口径校验与 WP9 字段共管;S0 五表无来源列则 WP-D 脚本增列。S0 页面镜像只读化属前端改动,**不在本任务书** | 方案 §7.3「走 S0 领域服务」在现有代码中的落点即上述组合;若负责人认为需先建独立 S0 写入服务层,WP-D 停工等决策 |
+| D-4 | 回执 HTTP 语义:摘要不符 400、签名/时间窗/nonce 401(§5.2 表) | 方案 §4.4 与 §8 的并集,已逐条对齐 |
+
+---
+
+## 12. 验收标准
+
+> 通用环境:本地 `dotnet run`(`Admin.NET.Web.Entry`);测试对接方 `access_key=TESTKPARTNER`;测试实体 `S1_SALES_ORDER_ENTRY`(`inbound_enabled=1`)。签名构造参照 `tools/mock/inbound/push_demo.py`(WP-F 前先用手工脚本/curl 亦可)。
+
+### V1 认证负例(方案验收 #1/#2)
+
+| 用例 | 期望 |
+|------|------|
+| 无签名头 | 401 |
+| 错 secret 签名 | 401 |
+| `X-Timestamp` 超 ±5min | 401 |
+| 同 nonce 5 分钟内重放 | 401 |
+| 缺 `Idempotency-Key` | 400 |
+| body 改一字节(头不动) | 400 `content digest mismatch` |
+| 剥离/替换 `Idempotency-Key` 后重签或不重签 | 400/401 |
+
+### V2 scheme 隔离(方案验收 #1 末条)
+
+用入站签名凭证调任意登录态业务 API(如 `GET /api/AidopKanban/...`)→ **401**;用登录 JWT 调 `POST /api/mdp/inbound/...` → **401**(JWT 不是该端点的 scheme)。
+
+### V3 授权
+
+未授权实体(grant 无行)→ 403;grant `status=0` → 403;body 行内 `tenant_id` ≠ 绑定租户 → 整批 403;grant 绑工厂 + 行内异工厂 → 该行 rejected;超 `rate_limit_per_min` → 429;超行数 → 413。
+
+### V4 幂等状态机(方案验收 #3)
+
+同 Key 同 body 两次 → 第二次 202 `idempotentReplay=true` 且 stg 仍一行;同 Key 不同 body → 409;并发同 Key → 一 202 一 409 `in-progress`;COMMITTED 前杀进程(或事务内抛错)→ 状态落 `FAILED`/残留 `RECEIVED`,同 Key 重试成功且**无半批**:
+
+```sql
+-- 无「request COMMITTED 但 stg 行数 < accepted」的假回执
+SELECT r.id, r.accepted, r.status FROM mdp_inbound_request r WHERE r.status='COMMITTED' AND r.accepted=0 AND r.rejected=0;
+-- 应为空集(除非报文本身全拒,此时 result_code=2)
+```
+
+### V5 行级契约(方案验收 #4/#5)
+
+缺 `sourceUpdatedAt` 或无时区偏移 → 行级拒绝;`bizKey` 与 `biz_key_expr` 计算值不符 → 行级拒绝;同行仅大小写不同的重复键 → 整批 400;有拒绝行 → HTTP 202 且 `code=2`,`rejectedRows` 含原因。
+
+### V6 行级幂等与 stale(方案验收 #4)
+
+同 bizKey 重推 → stg 一行(`source_row_id=bizKey`);旧 `sourceUpdatedAt` 重推 → rejected `reason=stale` 且 `stale_rejected=1`;同时间戳不同内容 → 接受且 `equal_ts_override=1`;带 `sourceVersion` 时优先于时间戳裁决。
+
+### V7 跨通道过户(方案验收 #4 首条,D-1)
+
+同一 bizKey 先由 PULL 落库(`source_system='AIDOPDEV_MYSQL'`),再 INBOUND 推送 → stg **仍一行**且 `source_system` = 入站源 source_code、`source_row_id` = bizKey。
+
+### V8 信封归档
+
+每次请求 `mdp_inbound_envelope` 一行,`body_sha256` 与原始 body 一致,`headers_json` 不含 `X-Signature`/`Authorization`。
+
+### V9 事件加急(方案验收 #6)
+
+推送 202 后 5 分钟内:`mdp_std_*` 对应业务键可查、`ado_s9_kpi_value_l1_day` 当日值刷新;`mdp_inbound_request.receipt_json` 的 `transformEnqueued=true`。
+
+### V10 快照(方案验收 #9)
+
+open→带 `snapshotId`+`seq` 推两批→commit;未 commit 前 stg 无删除语义;`seq` 跳号 → 400;commit 后批次外缺失行按口径软标记;构造 >20% 差集 → 409 `DIFF_BLOCKED` 不执行;快照超 24h 未 commit → 再写/commit 判过期。
+
+### V11 主数据镜像(方案验收 #7)
+
+推 `MDM_ITEM` 后 `ItemMaster` 镜像行可见、`source_system` 为入站源;本库手建同编码物料被推送撞键 → `mdp_inbound_conflict` 落 PENDING 行、镜像未被覆盖。
+
+### V12 T8 等价实体(WP-E)
+
+`S5_INVENTORY_TXN` 推送落 `mdp_stg_t8_*`,既有 `dwd_t8_*` 转换跑通,S5 周转格子有数(无 T8 现场口径)。
+
+### V13 沉默告警
+
+把某实体 `silence_alert_hours=1`,最后一笔 COMMITTED 超过 1 小时 → 健康检查 Job 产出告警记录;`silence_calendar` 不含当天 → 不告警。
+
+### V14 内部口隔离(方案验收 #8)
+
+`POST /api/AidopKanban/s1-mdp/inbound` 用入站签名凭证调用 → 401(该口仍只走登录态)。
+
+---
+
+## 13. 本任务书明确不做的事
+
+- 不改框架 Core 任何文件(含 `SignatureAuthenticationHandler`、`SysOpenAccessService`、`Startup.cs`@Web.Core)
+- 不改 `MdpStagingWriter` / `MdpDbPullExecutor` / `MdpApiPullExecutor` / Outbox 任何行为
+- 不建入站管理前端页(FUNC 编号登记随后续前端任务书);grant/field_map 首期 SQL 维护
+- 不做 S0 页面镜像只读化(前端,另立项)
+- 不做转换层「按 sync_batch_id 只转本批」引擎与历史 biz_date 窗口改造(§7.2 核查结论若需要,另开任务书)
+- 不开放 `MDM_BOM_REVISION` / `MDM_BOM_COMPONENT`(等双模 BOM 权威表)
+- 不修 `api/aidop/out` 鉴权(P-023);不改 PULL `mdp_field_mapping`
+- 不新建 `mdp_stg_external_row` 贴源模型;不给 stg 表加 `source_updated_at` 专用列(D-2)
+- ~~不改 `系统集成/Ai-DOP第三方系统集成指南.md`~~(阶段 G:2026-09-11 已按终态写入第 4 章;发布门禁仍见 P-046 / G1)
+
+---
+
+## 14. 回滚
+
+- WP-A/B/C 同批发布:回滚 = `git revert` 对应三个提交 + 停用在库 `inbound_enabled`(`UPDATE mdp_entity SET inbound_enabled=0 WHERE ...`)。新表保留不 DROP(无下游依赖)。
+- WP-D/E/F 各自 `git revert` 即可;快照 DIFF_BLOCKED 状态行不回滚(人工核对记录)。
+- 回滚后第三方推送全部 403/404,不产生数据损坏;已落 stg 的行保留,PULL 兜底通道不受影响。
+
+---
+
+## 15. 提交要求
+
+- 每 WP 独立提交;含后端代码/配置的提交按 [`.cursor/rules/version-bump-on-commit.mdc`](../../.cursor/rules/version-bump-on-commit.mdc) 将 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 三处版本号 patch +1(`1.0.526 → 1.0.527 → …`,以提交时实际为准)。无前端改动,**不递增** `Web/package.json`。
+- SQL 脚本提交时改名 `1.0.<next>.sql`(与版本号同批同号),配 `.verify.sql`,登记 csproj Copy(先例 `1.0.359.sql`)。
+- 「提交」= commit + push 到跟踪远端([`.cursor/rules/git-submit-intent.mdc`](../../.cursor/rules/git-submit-intent.mdc));未经用户明示「提交」不得擅自提交。
+
+---
+
+## 16. 执行记录(执行者逐 WP 回填)
+
+| 日期 | WP | 版本 | 结果 | 备注 |
+|------|----|------|------|------|
+| 2026-09-10 | G1 | — | 已起草字段对表;门禁口径同日调整 | [`第三方标准API推数入站-阶段0字段对表草稿.md`](./第三方标准API推数入站-阶段0字段对表草稿.md)。负责人决策:编码不设门禁(契约 v1-draft 先行),双方会签 + 库存口径 F1–F5 书面确认转为**发布门禁(阶段 G 前)**;T8 库存口径编码期间并行确认 |
+| 2026-09-10 | G2 | — | 已确认 | 负责人确认 §11 实施决策 D-1~D-4;总体方针:**按最优目标态执行,不做临时过渡设计** |
+| 2026-09-10 | WP-A | 1.0.536 | 编码完成,待编译/脚本验收 | 6 表 + mdp_entity 5 列;InboundSignature scheme;grant 授权限流;联调种子 TESTKPARTNER / TEST_INBOUND_ERP。号段避让远端已占用的 1.0.527。V1–V3 待 WP-B 控制器落地后跑 |
+| 2026-09-10 | WP-B | 1.0.537 | 编码完成,待联调验收 | Controller + Receive 流水线 + FieldMapper + schema/receipts;GET 签名用实际 METHOD;S1/物料转换未滤 is_deleted,实体标注 inbound_delete=unsupported。号段避让远端已占用的 1.0.528。V1–V8 待库脚本生效后跑 |
+| 2026-09-10 | WP-C | 1.0.529 | 编码完成,待联调验收 | `MdpInboundModuleTrigger` 接线(202/409 记 `transformEnqueued=true`);`factoryId` 不能传 0(Rebuild 会抛),grant 工厂或回落 1;健康检查增加 API_INBOUND 探活跳过 + 沉默告警。V9/V13 待后端拉起后构造 |
+| 2026-09-11 | WP-D/E/F | 1.0.538 | 编码完成,待联调验收 | 快照 open/commit/差集;S0 五表来源列 + 镜像;T8/员工 5 实体登记(Q7 方案 A);digest/bulk;Mock `tools/mock/inbound/push_demo.py`;P-2 卡片已更名;P-011 预登记 envelope 30d / request ≥7d。V10–V12 待库脚本 + 后端拉起 |
+| 2026-09-11 | 阶段 G / 待办 | — | 指南已写;发布未关 | 集成指南第 4 章按全部完成后的方式丙写入。剩余收口登记项目待办 **P-046**(G1 会签、V10–V14、阶段 H、S0 页只读) |
+
+### §7.2 核查结论(Rebuild 历史 biz_date 窗口)
+
+**不支持**任意历史 `biz_date` 窗口。证据:
+
+- `ModuleRebuildService.EnqueueAsync`(`DataPlatform/MdpRebuild/ModuleRebuildService.cs` 第 31–37 行)只有 module/tenant/factory/trigger,无窗口参数。
+- S1–S4 handler 一律 `RunFullAsync` 整模块全量(`ModuleRebuildHandlers.cs`);S5–S7 走 `AidopT8KpiManualRefreshService.RunModuleRefreshAsync`,同样无调用方指定日期。
+- S1 KPI 只滚近 14 天:`S1MdpSyncTransformService.BuildS1KpiValuesAsync` 第 483–486 行 `dayOffset = 13..0`。
+- S5 L2 只从默认 `BizDate`(昨天,`S5MdpRefreshOption.Default` 第 1023–1033 行)回填 14 天:同文件第 116–121 行。
+
+补推上月收货等超出滚动窗的数据,不能靠当前 Rebuild 重算对应历史 KPI。方案 §7.2 待办已标注;改造列入本任务书 §13,另开任务书。
+
+### §8.2 前置核查结论(S0 五表来源列 + 写入路径清单)
+
+**核查日**:2026-09-11。
+
+1. **来源列**:`ItemMaster` / `CustMaster` / `SuppMaster` / `LocationMaster` / `EmployeeMaster` 原先均无 `source_system`、`source_updated_at`。已在 `1.0.538.sql` 幂等增列;对应 `AdoS0*Master` 实体已补属性。
+2. **写入路径清单**(无独立「S0 领域服务」层,D-3 落点即此组合):
+   - 页面 CRUD:`Controllers/S0/` + SqlSugar 仓储(物料/客户/供应商/库位/雇员各自 Controller)。
+   - `AdoS0ReferenceChecker`:**删除前**下游引用检查 + 保存侧存在性/作用域(物料、库位、部门)。**不是**完整 upsert 领域服务。
+   - 入站镜像:`MdmMirrorUpsertService` 经 S0 实体类 + SqlSugar upsert;存在性调用 `LocationExistsAsync` / `DepartmentExistsAsync`。物料**更新**跳过 WP9 WMS 共管字段:`Location` / `DefaultShelf` / `SafetyStk` / `LotSerialControl` / `AllocateSingleLot`。手建行(`source_system` 空)不覆盖,落 `mdp_inbound_conflict` `MANUAL_ROW_EXISTS`。
+   - S0 页面镜像只读化:**不在本期**。

+ 588 - 0
doc/plan/第三方标准API推数入站方案.md

@@ -0,0 +1,588 @@
+# 第三方标准 API 推数入站最佳方案(方式丙 · 全量推送模式)
+
+| 项 | 内容 |
+|----|------|
+| 文档定位 | **目标架构与实施总方案**。适用前提:**所有数据都来自第三方系统主动调用我方标准 API 推送**,并保证九宫格看板、各模块详情看板、智慧诊断、S8 监控、ChatBI 全部可用 |
+| 编写日期 | 2026-09-06(初版);2026-09-06 升级为全量推送最佳方案;2026-09-10 v2 按评审修订(签名含 body 摘要、快照 commit 协议、幂等状态机等,见变更记录) |
+| 状态 | 执行任务书已派生(见下「执行任务书」行),编码经任务书执行;**本文不授权直接改代码** |
+| 对应待办 | [`UAT测试/内部UAT测试待办项.md`](./UAT测试/内部UAT测试待办项.md) **UAT-INT-004**;关联 **UAT-INT-003/006**(S0 主数据闭环,本方案纳入主路径,见 §7.3);交付任务书 **P-2** |
+| 数据需求基线 | [`系统集成/Ai-DOP第三方对接业务数据详表-业务页.md`](./系统集成/Ai-DOP第三方对接业务数据详表-业务页.md)、[`系统集成/Ai-DOP第三方对接业务数据详表-技术页.md`](./系统集成/Ai-DOP第三方对接业务数据详表-技术页.md)(本方案 §6 实体矩阵以此为准) |
+| 对外指南 | [`系统集成/Ai-DOP第三方系统集成指南.md`](./系统集成/Ai-DOP第三方系统集成指南.md)(**2026-09-11 已写入方式丙**;现场按 grant 白名单开通,未开通 403) |
+| 相邻 | [`S0未走数据中台的问题.md`](./S0未走数据中台的问题.md);[`AIDOP标准与非标双模BOM升级方案.md`](./AIDOP标准与非标双模BOM升级方案.md) §6 |
+| 评审 | [`针对第三方标准API推数入站方案的评审.md`](./针对第三方标准API推数入站方案的评审.md)(v2 修订依据;作者复核结论见其文末) |
+| 执行任务书 | [`第三方标准API推数入站执行任务书.md`](./第三方标准API推数入站执行任务书.md)(P-2 执行任务书,2026-09-10 按方案 v2 派生;开工门禁见其 §1) |
+
+---
+
+## 0. 一句话
+
+第三方用**我们签发的机器凭证(身份复用框架 `SysOpenAccess`,认证为插件侧独立 `InboundSignature` scheme,签名串含 body 摘要)**,向统一入口 `POST /api/mdp/inbound/{entityCode}` 推业务行;落库复用现有 `MdpStagingWriter` 进既有 `mdp_stg_*`,走**同一套标准层转换**(S1–S7 `*MdpSyncTransformService`)到 `mdp_std_* / dwd_* / ado_s9_kpi_value_l*_day`,五大消费端(九宫格 / 详情看板 / 智慧诊断 / S8 / ChatBI)原样可用,**消费侧零改动**。
+
+名称必须拆开(现网已混用 `API_PUSH`):
+
+| 名称 | 谁主动 | 现网 |
+|------|--------|------|
+| `API_PULL` | 我方 GET 对方 | `MdpApiPullExecutor` 已有 |
+| `API_INBOUND` | **对方 POST 我方** | **无(本文)** |
+| 出站发件箱 | 我方 POST 对方 | `MdpApiPushExecutor` / `mdp_outbox`(**不要再叫入站**) |
+| `API_OUT` | 对方 GET 我方 KPI | `GET /api/aidop/out` |
+
+任务书 P-2 写的 `API_PUSH` 入站 Webhook,实施时改称 **`API_INBOUND`**,避免和出站执行器同名。
+
+---
+
+## 1. 目标场景与设计前提
+
+### 1.1 场景前提(与旧版草案的关键差异)
+
+| # | 前提 | 对方案的影响 |
+|---|------|--------------|
+| 1 | **所有**业务数据来自第三方推送 | 方式丙不是「并列第三选项」,而是**标准主入站方式**;甲(DB 直连)/乙(我方拉)降级为迁移期过渡与兜底 |
+| 2 | 五大消费端全部可用 | 实体覆盖必须是**全集**(§6),不是按痛点分批开几个;转换时效必须事件驱动,不能只看 60 分钟定时 Job |
+| 3 | 没有我方库直连兜底 | 主数据必须**回灌 S0 业务表**(§7.3),否则评审 / 开工单 / 名称解析断裂;T8 指标账必须有**推送等价实体**(§7.4),否则 S5–S7 / S9 周转、人效格子空 |
+| 4 | 对方系统异构(ERP/MES/WMS/QMS/SRM 多家) | 契约必须稳定、自描述、可灰度;凭证按「对接方 × 系统」签发,实体白名单按方授权 |
+
+### 1.2 目标
+
+1. 一套对外入口,按 `entity_code` 路由到已登记实体;未登记或未开通入站返回明确错误。
+2. 行契约与方式乙列表元素一致(`bizKey` + 业务字段),对外用规范字段、经 `mdp_field_map` 映射为现有转换 SQL 从 `raw_data` JSON 读取的键(大小写不敏感,§4.3),映射与转换零分叉。
+3. 鉴权独立于登录 JWT:身份复用框架 `SysOpenAccess`(AccessKey/Secret、绑租户、管理界面),认证管线用**插件侧独立 scheme `InboundSignature`**(签名串含 body 摘要与 `Idempotency-Key`,不改框架 Core);机器凭证绑定租户(及可选工厂);**禁止** `[AllowAnonymous]`,**禁止**调用方在 body 里指定任意 `tenantId`。
+4. 幂等双保险:同一业务键重复推 = 覆盖更新不双行(行级);同一 `Idempotency-Key` 重放 = 同一回执不重复记账(请求级)。
+5. HTTP 在贴源成功后立即回执;`stg→std→dwd/KPI` **事件驱动加急**(复用各模块 `*RebuildService.EnqueueAsync` 单飞重建),定时 Job 兜底;看板可见时效目标 **分钟级**。
+6. 主数据推送直接形成 S0 业务表镜像(外部真源、本库只读),评审 / 工单 / 看板名称解析同源一致。
+7. 可观测、可对账:每次请求入审计表;按批次可查接收清单;配置中心可见入站开通与调用健康度。
+
+### 1.3 非目标
+
+- 不把 `POST /api/AidopKanban/s*-mdp/inbound` 对外开放(那是内部抽数开关)。
+- 不把 `GET /api/aidop/out` 解释成入站(P-023 另案加鉴权,凭证模型预留 `OUT_KPI` 域)。
+- 不在本通道实现「对方推完立刻改订单状态 / 开工单」等业务副作用(出站回写仍走 Outbox)。
+- 不新建并行贴源模型(不用 `mdp_stg_external_row` 当长期真源)。
+- 不改 165、不恢复 Mongo。
+- 不替代双方 ETL 对账的全部人工流程;提供对账 API 与批次追踪即可。
+
+---
+
+## 2. 消费端数据需求基线(为什么这些实体一个都不能少)
+
+五大消费端**都不直连第三方**,全部读中台产物(技术页 §10 已定义,此处固化为验收口径):
+
+| 消费端 | 读取对象 | 对入站的要求 |
+|--------|----------|--------------|
+| **九宫格看板** | `ado_s9_kpi_value_l1_day` + KPI 主数据 / 布局 | 推送数据经转换算出 L1 日值;**关键日期与数量字段缺一不可**(业务页「必接」列) |
+| **模块详情看板(S1–S7/S9)** | L1–L4 KPI 日值 + 各模块 `dwd_*` / `mdp_std_*` | 在九宫格基础上还要单号、物料、客户/供应商、状态等下钻字段 |
+| **智慧诊断** | KPI 树(同上)+ 依据:`mdp_stg_so` 评审流、`dwd_order_schedule_trans`、`dwd_supplier_delivery`、`dwd_material_readiness`、`mdp_std_s6_report`、`qms_qcp_inspbill`、`mdp_std_fqc_result`、`mdp_std_purchase_*` | 必须推**单据行级明细**,不能只推汇总;评审流各阶段起止时间在 `raw_data` 里必须有 |
+| **S8 监控** | 守望规则引擎基于 KPI 快照(`S8KpiSnapshotService` 读 `ado_s9_kpi_value_l1_day`)与标准层业务数据自生成异常 | **不需要第三方送异常单**;但底层指标与业务行必须推到,否则规则无输入、监控静默 |
+| **ChatBI** | **仅** `ado_s9_kpi_value_l1~l4_day` + KPI 主数据 | 不另要一套数;KPI 日值算出即可问 |
+
+推论:**方案成败 = 推送实体全集 × 每实体必填字段 × 转换链路时效**。§6 矩阵即按此推导。
+
+---
+
+## 3. 总体架构
+
+```text
+第三方 ERP / MES / WMS / QMS / SRM
+        │
+        │  POST /api/mdp/inbound/{entityCode}
+        │  AccessKey 签名头 + Idempotency-Key
+        ▼
+┌─────────────────────────────────────────────────┐
+│ 网关层:HTTPS 终结、IP 白名单(可选)、全局限流    │
+│ 认证层:插件侧 InboundSignature scheme(身份复用    │
+│         SysOpenAccess 绑租户/机器用户,注入 Claims)│
+│ 授权层:mdp_inbound_grant(实体白名单/限流/工厂)  │
+│ 接收层:报文校验 → 请求幂等 → MdpStagingWriter    │
+│         (按 source_biz_key upsert 进 mdp_stg_*) │
+│ 触发层:写入后 Enqueue 所属模块重建(单飞去抖)     │
+│ 审计层:mdp_inbound_request 状态机 + 信封归档 + mdp_sync_log │
+└───────────────────┬─────────────────────────────┘
+                    │ 202 Accepted + 批次回执
+                    ▼
+              同一张 mdp_stg_*
+                    │
+                    ▼
+        既有 S1–S7 *MdpSyncTransformService
+        (主数据实体同时触发 S0 业务表镜像 upsert,§7.3)
+                    │
+                    ▼
+        mdp_std_* / dwd_* / ado_s9_kpi_value_l1~l4_day
+                    │
+        ┌───────────┼───────────┬───────────┐
+        ▼           ▼           ▼           ▼
+     九宫格      详情看板     智慧诊断     ChatBI
+                    ▲
+                  S8 监控(规则引擎读 KPI 快照 + 标准层,自生成异常)
+```
+
+与甲/乙的关系(迁移期并存,目标态以丙为主):
+
+| | 方式甲 DB | 方式乙 PULL | **方式丙 INBOUND(目标主方式)** |
+|--|-----------|-------------|-------------------|
+| 谁主动 | 我方 SELECT | 我方 GET | **对方 POST** |
+| 落点 | `MdpStagingWriter` | 同左 | **同左** |
+| 转换 | 模块作业 | 同左 | **同左 + 事件加急** |
+| 增量 | 对方时间列 | cursor | **对方决定何时推** |
+| 删除 | 软删状态 | 同行 | 行上 `op=delete` / 全量快照缺失标记 |
+| 定位 | 迁移期过渡 | 迁移期过渡 / 兜底补拉 | **标准** |
+
+同一对象双源登记时:后写覆盖先写(叠加 §7.5 乱序裁决),`source_system` 保留最后来源。现场默认「一对象一种入站」;双开仅用于迁移期对照联调。
+
+---
+
+## 4. 对外契约(一次定死,版本化演进)
+
+### 4.1 路径与方法
+
+```http
+POST /api/mdp/inbound/{entityCode}
+Content-Type: application/json
+```
+
+- `{entityCode}` = `mdp_entity.entity_code`(见 §6 矩阵,如 `S1_SALES_ORDER`、`S5_PURCHASE_RECEIPT_DETAIL`、`MDM_ITEM`)。
+- 只接受 POST;不提供「一个超级 `/inbound` 塞任意对象」的无登记通道。
+- 批量:一请求一个数组,上限 **500 行**(主数据快照类实体可配置放宽到 2000);超出 `413`。
+- 配套端点(同一签名体系):`GET /api/mdp/inbound/{entityCode}/schema`(对外规范字段 JSON Schema,§4.3);`POST .../snapshots` 与 `POST .../snapshots/{snapshotId}/commit`(全量快照 open/commit 协议,§4.3);`POST .../bulk`(初始化批量装载,§7.8)。
+- 契约版本:首版 `/api/mdp/inbound/` 隐含 v1;破坏性变更走 `/api/mdp/v2/inbound/...`,老版本至少并存一个发版周期。
+
+### 4.2 认证(身份复用框架 + 插件侧独立签名 scheme + 入站授权扩展)
+
+**身份**:复用 Admin.NET 框架既有 `SysOpenAccess`(开放接口身份表、管理界面、缓存、`SysOpenAccessService.GetByKey` 取密钥):
+
+- 我方为每个「对接方 × 系统」签发 `AccessKey` / `AccessSecret`(只带外交付一次,库内可加密;后台已有管理界面与缓存)。
+- 凭证**绑定租户**(`BindTenantId`)与一个**专用机器用户**(`BindUserId`,新建无菜单权限的对接账号);验签通过后注入 `TenantId` 等 Claims,多租户隔离与现有 `MdpStagingWriter` 租户解析天然一致。
+
+**认证管线**:插件侧注册独立 AuthenticationScheme **`InboundSignature`**(自实现 handler,**不改框架 Core**)。背景(评审 P0-1,源码复核确认):框架 `SignatureAuthenticationHandler.GetMessageForSign` 为 `private static`,签名串仅「方法&路径&key&时间戳&nonce」且**无扩展钩子**——body 摘要不进签名串,则中间环节可同时篡改 `list` 与 body 内 `contentHash` 并通过服务端重算,只防截断不防篡改。入站 handler 复用 `GetByKey` 取密钥、参照框架实现时间窗与 nonce 缓存去重:
+
+```text
+X-Access-Key:       {accessKey}
+X-Timestamp:        {unix 秒}
+X-Nonce:            {单次随机串}
+Idempotency-Key:    {请求级幂等键,必填,见 §4.4}
+X-Content-SHA256:   {sha256(原始请求体字节) 的 hex}
+X-Signature:        Base64( HMAC-SHA256(secret,
+  "POST&{path}&{accessKey}&{timestamp}&{nonce}&{contentSha256}&{idempotencyKey}") )
+```
+
+- **对原始请求体字节哈希**,禁止「规范化 JSON 后再哈希」——跨语言 JSON 规范化(键序、Unicode 转义、浮点表示)没有统一实现,异构对接方必然在联调期反复撞 400。
+- `Idempotency-Key` 进签名串,防剥离/替换。
+- 时间窗 ±5 分钟(与框架 `AllowedDateDrift` 默认一致);nonce 缓存去重窗口 = 时间窗 ×2(参照框架做法,与 Idempotency-Key 长窗口分储);签名错 / 过期 / 未知 key → `401`。
+- **scheme 隔离**(评审 P0-2 复核结论:风险不成立,断言保留):框架 Signature 为非默认 scheme,仅对显式 `[Authorize(AuthenticationSchemes=...)]` 的端点生效,全局默认策略走 JWT——入站凭证调任意登录态业务 API 必然 401。入站控制器显式 `[Authorize(AuthenticationSchemes = "InboundSignature")]`,回归用例见 §8。
+- **密钥轮换**:框架一 key 一 secret;`mdp_inbound_grant` 允许同一「对接方 × 系统」登记**多个 AccessKey**,轮换期双 key 并行灰度,旧 key 到期停用。
+
+**授权**(入站域扩展,插件侧新表,不动框架表):
+
+`mdp_inbound_grant`:`access_key`(关联身份,同一对接方可多 key)、`entity_code` 白名单、`factory_id` 可选绑定、`rate_limit`(默认 60 次/分、500 行/次)、`ip_allowlist` 可选、`status` 启停。
+
+- 对象不在白名单或实体未开 `inbound_enabled` → `403`。
+- body 里出现 `tenant_id` 必须与凭证绑定租户一致,否则整批 `403`(防 P-023 同类问题;v1 写 409 语义偏,对外发布前修正为 403)。
+- **工厂维度**:grant 绑定工厂时,行内工厂字段(`factory_id`)必须与绑定一致,不一致行级拒绝;grant 未绑工厂时行内可带工厂,多工厂推送逐行按 `MdpFactoryScope` 口径归一化校验。
+- 与「登录态业务 API」分域,与待补鉴权的 `api/aidop/out` 分域(预留 `OUT_KPI` 用途标识,避免两套 AppKey 体系)。
+
+> 与旧版草案差异:身份与管理界面复用框架 `SysOpenAccess`;认证管线**不复用**框架 `SignatureAuthentication`(签名串不可扩展),改为插件侧等价实现并扩展签名串,框架 Core 零改动。
+
+### 4.3 报文(与方式乙列表元素同形;字段两层映射)
+
+```json
+{
+  "code": 0,
+  "message": "ok",
+  "data": {
+    "snapshotId": null,
+    "seq": null,
+    "list": [
+      {
+        "bizKey": "100#SO-20260901-001#10",
+        "op": "upsert",
+        "sourceUpdatedAt": "2026-09-01T10:00:00+08:00",
+        "sourceVersion": 1234567,
+        "bill_no": "SO-20260901-001",
+        "entry_seq": "10"
+      }
+    ]
+  }
+}
+```
+
+body 完整性由签名头 `X-Content-SHA256` 保证(§4.2),信封内**不再携带** contentHash。
+
+| 项 | 约定 |
+|----|------|
+| 列表 | `data.list`;也允许根数组(与 PULL 一致) |
+| `bizKey` | **服务端按 `mdp_entity.biz_key_expr` 从业务字段计算**(评审 P1-8);对方可附 `bizKey` 作校验字段,与服务端计算不一致 → 行级拒绝。规则与集成指南第 4 章相同(多字段 `#` 连接) |
+| 业务字段 | **两层映射**(评审 P1-7):对外契约用 AiDOP 规范字段名(从 `mdp_entity` 元数据生成 JSON Schema,`GET /api/mdp/inbound/{entityCode}/schema` 可取);服务端按「实体 × 对接方」的 `mdp_field_map` 映射到 `raw_data` 键后走现有转换 SQL,**转换零改动**。首期规范字段 = 现有转换源键(成本为零),机制先立,避免把首个客户 ERP 的列名(`rdate`、`scm_shdzb` 等)固化成公共契约。大小写不敏感;同一行出现仅大小写不同的重复键 → 整批 400 |
+| `op` | 默认 `upsert`;`delete` = 软删(贴源行标记 `is_deleted`/状态,不物理删) |
+| `sourceUpdatedAt` | **必填**,ISO 8601 带时区偏移(如 `2026-09-01T10:00:00+08:00`),用于乱序裁决(§7.5)与对账;无时区偏移 → 行级拒绝 |
+| `sourceVersion` | 可选。源系统单调递增序号(如 binlog 位点/版本号),**提供时优先于时间戳**参与乱序裁决 |
+
+**全量快照 open/commit 协议**(替代 v1 的「批次结束自动差集」,评审 P0-4):
+
+1. `POST /api/mdp/inbound/{entityCode}/snapshots` 开启快照 → 返回 `snapshotId`;
+2. 各数据请求带 `data.snapshotId` + `data.seq`(单调序号,服务端校验连续性,缺口告警);
+3. `POST /api/mdp/inbound/{entityCode}/snapshots/{snapshotId}/commit` 显式提交——**只有 commit 后才做差集**,本实体未出现于该快照的行按「源端已删/停用」软处理;
+4. 未 commit 的快照 24 小时(可配)过期作废,不产生任何删除语义——服务端无需猜测「对方是断了还是推完了」;
+5. 差集停用比例超阈值(默认 >20%,按实体可配)时**拒绝自动执行**,转人工确认后放行——防半截快照把主数据批量停用(§7.3 镜像随之放大事故)。
+
+禁止:任意 JSON 无实体映射就落库;一个请求混多个 `entityCode`;body 指定与凭证不一致的租户。
+
+### 4.4 回执与请求级幂等状态机
+
+贴源写入成功即返回,**不等待**模块转换:
+
+```http
+HTTP/1.1 202 Accepted
+```
+
+```json
+{
+  "code": 2,
+  "message": "partial",
+  "data": {
+    "entityCode": "S1_SALES_ORDER",
+    "accepted": 498,
+    "rejected": 2,
+    "rejectedRows": [ { "bizKey": "...", "reason": "缺必填字段 rdate" } ],
+    "idempotentReplay": false,
+    "syncBatchId": "INB_20260906_000123",
+    "requestId": "...",
+    "transformEnqueued": true
+  }
+}
+```
+
+- **有拒绝行时 `code≠0`**(`code=2 partial`,评审 P0-6):对接方 HTTP 客户端常只看 2xx,`code=0` + rejectedRows 会静默丢数;对账 digest 中 rejected 数量为独立字段;对方对接验收必须包含「处理 partial」用例。
+- stale 拒绝覆盖的行同样进 `rejectedRows`(`reason=stale`,按实体可配置关闭,§7.5)。
+
+| HTTP | 含义 |
+|------|------|
+| 202 | 已受理并写入贴源(或幂等重放);`rejectedRows` 给行级拒绝原因,有拒绝行时 `code=2` |
+| 400 | 报文/业务键不合法、缺 `Idempotency-Key`、`X-Content-SHA256` 与实际 body 不符、同行大小写重复键 |
+| 401 / 403 | 鉴权失败 / 对象未开通 / body 租户与凭证不一致 |
+| 409 | 同 `Idempotency-Key` 不同报文体;或同 Key 请求仍在处理中(in-progress) |
+| 413 | 超过行数/大小上限 |
+| 429 | 限流 |
+| 503 | 入站总开关关闭 |
+
+**请求级幂等与贴源写入原子性**(评审 P0-3;`Idempotency-Key` **必填**而非可选):
+
+`mdp_inbound_request` 增状态机 `RECEIVED → COMMITTED / FAILED`:
+
+1. 收到请求先插 `RECEIVED`(`Idempotency-Key` 唯一索引即并发锁):冲突且状态 `RECEIVED` → `409 in-progress`;冲突且 `COMMITTED` 且报文哈希相同 → 返回上次回执、`idempotentReplay=true`;冲突且哈希不同 → `409`;冲突且 `FAILED` → 允许同 Key 重试;
+2. stg 写入与状态置 `COMMITTED` 在**同一事务**——杜绝「半批写入 + 假回执 accepted=500」(`MdpStagingWriter` 现为逐行 upsert、无整批事务,本状态机是必须的补偿);
+3. 行级幂等仍由 `MdpStagingWriter` 按 `source_biz_key` upsert 兜底。
+
+**原始信封归档(首期必做,不再「至多」)**:每个请求的原始 HTTP 信封持久化保留 N 天(默认 30 天,随 P-011 清理窗口)——转换 SQL 出 bug 后可从信封重放,这是推送模式相对拉取模式唯一的补救手段。
+
+### 4.5 对账与回补(对第三方开放的最小读接口)
+
+| 接口 | 用途 |
+|------|------|
+| `GET /api/mdp/inbound/receipts/{syncBatchId}` | 按批次查接收计数、行级结果(同一签名体系) |
+| `GET /api/mdp/inbound/{entityCode}/digest?date=` | 按自然日返回该实体接收行数、**rejected 独立计数** + bizKey 清单哈希(清单按字典序升序拼接后 sha256,排序规则写入契约),供对方日终对账 |
+| 重推 | 任意历史行可随时重推,行级幂等保证不双行;大批量补推建议走全量快照模式 |
+
+---
+
+## 5. 限流与容量
+
+- 默认每 `access_key`:60 req/min、500 行/req、单行 `raw_data` ≤ 64KB(超限行为可配,默认整批拒)。
+- 实体级独立限流(库存流水类高频对象单独调大),配置存 `mdp_inbound_grant`。
+- **初始化装载不占日常推送通道**(评审 P1-11):60 rpm × 500 行 = 3 万行/分,三年单据 + 库存流水的首日全量要按天计。批量装载走独立模式(§7.8)。
+- 审计表按 P-011 保留窗口清理;`mdp_inbound_request` 存信封哈希 + 状态机 + 回执快照;原始信封归档保留 N 天(默认 30 天);P-011 清理窗口 ≥ `Idempotency-Key` 去重窗口(≥7 天,不短于对方重试策略上限)。
+
+---
+
+## 6. 实体全覆盖矩阵(API 覆盖范围的验收清单)
+
+「用在」图例:**宫**=九宫格,**详**=详情看板,**诊**=智慧诊断,**S8**=S8 监控输入,**问**=ChatBI(随 KPI 不单列)。必填字段以技术页各节「必」列为准,不再重复罗列。
+
+### 6.1 主数据(先于单据开通;推送同时回灌 S0 镜像,§7.3)
+
+| entityCode | 业务包 | 贴源/标准 | 用在 |
+|------------|--------|-----------|------|
+| `MDM_ITEM` | 物料 | `mdp_stg_item` → `mdp_std_item` → `ItemMaster` 镜像 | 详、诊(全链路名称解析) |
+| `MDM_CUSTOMER` | 客户 | `mdp_stg_customer` → `mdp_std_so` 维列 → `CustMaster` 镜像 | 详、诊 |
+| `MDM_SUPPLIER` | 供应商 | `mdp_stg_supplier` → `mdp_std_supplier` → `SuppMaster` 镜像 | 详、诊 |
+| `MDM_SOURCE_LIST` | 货源清单 | `mdp_stg_source_list` → `mdp_std_supplier_item` | 详、诊 |
+| `MDM_LOCATION` | 库位 | `mdp_stg_location`(现 `S5_LOCATION_*` 实体同源)→ `LocationMaster` 镜像 | 详 |
+| `MDM_EMPLOYEE_HEADCOUNT` | 各岗位人数(人效分母,可先只给岗位人数) | `mdp_std_t8_sys_pelist` 等价 → `EmployeeMaster`/岗位人数镜像 | 宫、问 |
+
+### 6.2 S1 产销协同(订单评审周期/满足率、主计划人效、成品库存周转)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S1_SALES_ORDER` | 销售订单头 | `mdp_stg_so` → `mdp_std_so` | 宫详诊问 |
+| `S1_SALES_ORDER_ENTRY` | 销售订单行 | 同上(行级,`bill_no,entry_seq` 为键) | 宫详诊问 |
+| `S1_CONTRACT_REVIEW` | 合同评审头+环节流(阶段/部门/起止必须在 raw) | `mdp_stg_so`(source_table 区分) | 宫详诊 |
+| `S1_SHIPPING_PLAN` | 发货计划 | → `mdp_std_ship_trans`(SHIP_PLAN) | 详诊 |
+| `S1_ASN_SHIPPER` | 发货单 ASN(实际发运日) | → `mdp_std_ship_trans`(ASN_SHIPPER) → `dwd_ship_trans` | 宫详诊问 |
+
+### 6.3 S2 制造协同(排程周期/满足率/人效、在制周转)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S2_WORK_ORDER` | 生产工单 | `mdp_stg_schedule`(WorkOrdMaster) → `mdp_std_work_order_schedule` → `dwd_order_schedule_trans` | 宫详诊问 |
+| `S2_OPERATION_SCHEDULE` | 日计划/工序排程 | `mdp_stg_schedule`(PeriodSequenceDet) → `mdp_std_operation_schedule` | 宫详诊 |
+| `S2_SCHEDULE_RESULT` | 排产结果(与上同表二源合一) | 同 `mdp_std_operation_schedule` | 宫详诊 |
+
+### 6.4 S3 供应协同(物料计划周期/满足率/人效、物料库存周转)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S3_SUPPLY_DEMAND` | 物料需求计划 | → `mdp_std_supply_demand` | 宫详诊 |
+| `S3_PURCHASE_REQUEST` | 采购申请 | → `mdp_std_purchase_request` | 详诊 |
+| `S3_PURCHASE_ORDER` | 采购订单头+行 | `mdp_stg_purchase_order` → `mdp_std_purchase_order` | 宫详诊 |
+| `S3_DELIVERY_SCHEDULE` | 交货计划(`submitdate`=S3 周期终点/S4 起点) | → `mdp_std_delivery_schedule` → `dwd_supplier_delivery` | 宫详诊 |
+| `S3_INVENTORY_ONHAND` | 库存现存量/齐套 | → `dwd_material_readiness` 链路 | 详诊 |
+
+### 6.5 S4 采购执行(交货周期/满足率、采购人效、在途周转)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S4_RECEIPT_CONFIRM` 入站向(收货确认行) | 交货/收货 | → `mdp_std_s4_*` → `dwd_s4_purchase_execution`/`dwd_po_trans` | 宫详诊问 |
+| `S4_SUPPLIER_SHIPMENT` | 供应商发货执行(送货单) | `scm_shdzb` 同构 → `mdp_std_s4_shipment` | 详诊 |
+| `S4_PURCHASE_RETURN` | 采购退货 | → `mdp_std_s4_return` | 详诊 |
+| `S4_IQC_RECEIPT` | 来料检验收货行 | `PurOrdRctDetail` 同构 → `mdp_std_s4_iqc` → `dwd_qc_trans` | 详诊 |
+| `S4_SHORTAGE` | 欠料 | → `mdp_std_s4_shortage` | 详诊 |
+
+### 6.6 S5 来料仓储(上线周期/满足率、仓储人效、物料周转)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S5_PURCHASE_RECEIPT` | 采购收货头+行 | `mdp_stg_purchase_receipt` → `mdp_std_purchase_receipt` | 宫详诊 |
+| `S5_PRODUCTION_ISSUE` | 生产领料/上线 | → `mdp_std_production_issue` | 宫详诊 |
+| `S5_PRODUCTION_RETURN` | 生产退料 | → `mdp_std_production_return` | 详 |
+| `S5_OUTSOURCE_ISSUE` | 委外发料 | → 对应 std | 详 |
+| `S5_IQC_RESULT` | 来料检验结论 | `qms_qcp_inspbill` 同构 | 诊 |
+| `S5_INVENTORY_TXN` | 出入库流水(T8 等价,§7.4) | `mdp_stg_t8_*` → `dwd_t8_material_*` | 宫(周转) |
+| `S5_INVENTORY_OPENING_BALANCE` | 库存期初余额(流水+期初自算结存,评审 P1-12) | `mdp_stg_t8_*` → `dwd_t8_material_*` | 宫(周转) |
+
+### 6.7 S6 制造执行(制造周期/满足率/人效;S9 生产效率)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S6_REPORT` | 车间报工 | `mdp_stg_s6_report` → `mdp_std_s6_report` | 宫详诊问 |
+| `S6_IPQC_INSPECTION` | 过程检验头+行 | `mdp_stg_ipqc_pull` → `mdp_std_ipqc_inspection` | 详诊 |
+| `S6_LINE_RUNTIME` | 产线运行/停机 | `LineStatusDet` 同构 → S9 效率口径 | 宫、问 |
+
+### 6.8 S7 成品仓储 + S9 全场(发货周期/满足率、仓储人效、交付/质量/全库存周转/生产效率)
+
+| entityCode | 业务包 | 落点 | 用在 |
+|------------|--------|------|------|
+| `S7_PRODUCTION_RECEIPT` | 成品入库 | → `mdp_std_production_receipt` | 详诊 |
+| `S7_FQC_RESULT` | 成品检验(报检日=发货周期 D1) | `mdp_stg_fqc_pull` → `mdp_std_fqc_result` | 宫详诊 |
+| `S7_QUALITY_RETURN` | 质量退货 | → S9_L1_001 口径 | 宫、问 |
+| `S7_FINISHED_ONHAND` | 成品库存结存(T8 等价,作校验源) | → `dwd_t8_finished_*` | 宫、问 |
+| `S7_FINISHED_OPENING_BALANCE` | 成品期初余额(T8 等价,评审 P1-12) | → `dwd_t8_finished_*` | 宫、问 |
+| (复用 `S1_ASN_SHIPPER`) | 销售发货 | `dwd_ship_trans` | 宫详诊问 |
+
+矩阵合计 **约 30 个实体**;每个实体与现有 DB/PULL 实体**共用 `target_table_name` 与转换 SQL**,只是来源通道不同。
+
+---
+
+## 7. 内部实现要点
+
+### 7.1 组件清单
+
+| 组件 | 职责 | 新建/复用 |
+|------|------|-----------|
+| 框架 `SysOpenAccess`(身份表/管理界面/`GetByKey`) | AccessKey/Secret 存取、绑租户/机器用户 | **复用**(Core 已有) |
+| 插件侧 `InboundSignature` scheme + handler | 验签(签名串含 body 摘要 + Idempotency-Key)、时间窗、nonce 缓存去重、Claims 注入 | 新建(插件侧,**不改 Core**) |
+| `mdp_inbound_grant` 表 + `MdpInboundAuthService` | 实体白名单、工厂绑定、限流、IP、同一对接方多 key | 新建(插件侧) |
+| `mdp_field_map` 表 + 字段映射器 + `/schema` 端点 | 对外规范字段 ↔ raw_data 键两层映射(§4.3) | 新建(插件侧) |
+| `MdpInboundReceiveService` | 报文校验、请求幂等状态机、调 Writer、记审计与信封归档、触发重建 | 新建 |
+| `MdpInboundController` | `[Route("api/mdp/inbound")]` + snapshots/schema/bulk 端点,唯一对外控制器 | 新建 |
+| `mdp_inbound_request` 表 | 请求级幂等(`Idempotency-Key` 唯一索引)、RECEIVED→COMMITTED/FAILED 状态机、信封哈希、回执快照 | 新建 |
+| `mdp_inbound_envelope` 表 | 原始 HTTP 信封归档(N 天),转换 bug 后可重放 | 新建 |
+| `mdp_inbound_snapshot` 表 | 快照 open/commit/过期/差集阈值守卫(§4.3) | 新建 |
+| `mdp_inbound_conflict` 表 | 主数据镜像键冲突人工裁决(§7.3) | 新建 |
+| `MdpStagingWriter` | 贴源 upsert | **复用,禁止另写贴源 SQL** |
+| S1–S7 `*MdpSyncTransformService` + `ModuleRebuildService.EnqueueAsync` | stg→std→dwd→KPI;事件加急(DB 持久化队列,见 §7.2) | **复用** |
+| `mdp_entity` | 增 `inbound_enabled`、推送实体登记、对外规范字段元数据 | 扩列 + 配置 |
+| 主数据镜像服务 `MdmMirrorUpsertService` | 主数据 stg→S0 业务表 upsert,**走 S0 领域服务**(§7.3) | 新建 |
+
+### 7.2 转换何时跑(时效目标:分钟级)
+
+1. **事件加急(主)**:接收成功后按实体所属模块调既有 `ModuleRebuildService.EnqueueAsync(moduleCode, tenantId, factoryId, ...)`。源码复核确认(评审 P1-10 前提不成立):该队列为 **DB 持久化**(`AdoModuleDashboardRebuildJob` 表),`ModuleRebuildWorker` 空闲每 5 秒轮询认领 + `IModuleRebuildLock` 租约锁单飞——多实例安全、进程重启不丢、最坏延迟**秒级**而非「60 分钟」;同 scope 重复入队自动 409 合并,洪峰推送天然去抖为有序重建。MDM 实体不属任何单一模块,其触发范围见 §7.7 依赖图。
+2. **定时兜底(备)**:现有 `S1–S7 MdpSyncTransformJob`(60 分钟周期)继续跑,吸收事件丢失场景。
+3. 看板/诊断/ChatBI **不在 HTTP 请求内**同步重算;调用方预期 = 推送成功后分钟级可见。内部保留「按 `sync_batch_id` 只转本批」运维接口(不对第三方开放)。
+4. **遗留待办**:重建现为整模块粒度,高频推送下同一模块反复重建存在资源浪费,后续评估增 `mdp_rebuild_request` 持久化触发表、按 biz_date 窗口增量合并;**需核**现有 Rebuild 是否支持任意历史 biz_date 窗口重算——若不支持,§7.5「补推上月收货触发历史窗口 KPI 重算」需先改造转换层。
+   **已核查(2026-09-10,WP-C)**:不支持任意历史 `biz_date` 窗口。`ModuleRebuildService.EnqueueAsync` 无窗口参数,handler 一律 `RunFullAsync` 整模块全量;S1 KPI 仅滚动近 14 天(`S1MdpSyncTransformService` 第 483–486 行),S5 L2 仅从默认 `BizDate`(昨天)回填 14 天(`S5MdpSyncTransformService` 第 116–121 行)。补推历史数据需先改造转换层,已列入执行任务书 §13,另开任务书。
+
+### 7.3 主数据回灌 S0(本方案主路径,落地 UAT-INT-003 决策)
+
+全量推送模式下没有本库手工维护兜底,主数据必须形成 S0 业务表镜像:
+
+| 约定 | 内容 |
+|------|------|
+| 真源 | 外部系统;本库 `ItemMaster` / `CustMaster` / `SuppMaster` / `LocationMaster` / `EmployeeMaster` 为**只读镜像**(按 `source_system` 区分,本库手建行不受推送影响) |
+| 时机 | 主数据实体转换成功后在同一重建流程内 upsert 镜像;冲突以 `sourceUpdatedAt` 新者胜 |
+| 写入路径 | **镜像 upsert 走 S0 领域服务,禁止直写表**(评审 P1-13)——S0 侧校验、审计、缓存失效、WP9 共管字段保护必须全部走到 |
+| 键冲突 | 同一物料/客户/供应商编码「本库手建过」与「推送」撞键时,**先落 `mdp_inbound_conflict` 冲突表人工裁决,不覆盖** |
+| 不写 | 不回写对方系统;不覆盖 WMS/165 侧已管字段(沿用 WP9 字段共管口径) |
+| BOM | 目录 BOM 走 `AIDOP标准与非标双模BOM升级方案.md` §6 权威表落地后再开通推送;本方案预留 `MDM_BOM_REVISION` / `MDM_BOM_COMPONENT` 实体位,**不在首期** |
+
+影响面须随立项书再确认:S0 页面对镜像行只读化、评审/选料读同一套 ItemMaster 后行为不变。
+
+### 7.4 T8 指标账的推送等价(S5–S7/S9 周转、人效不空格)
+
+现网 S5–S7 指标常用 T8 只读库(`kc_tz_*`/`kc_dd_*`/`sys_pelist` → `dwd_t8_*`)。全量推送模式:
+
+1. 定义 T8 等价推送实体(§6.6/6.8 的 `S5_INVENTORY_TXN`、`S5_INVENTORY_OPENING_BALANCE`、`S7_FINISHED_ONHAND`、`S7_FINISHED_OPENING_BALANCE`、`MDM_EMPLOYEE_HEADCOUNT` 等),**字段与 T8 源表列同名**,落 `mdp_stg_t8_*`,复用现有 `dwd_t8_*` 转换与 KPI 口径,消费端零感知。
+2. **结存口径**(评审 P1-12):很多 ERP 只有出入库流水、没有结存快照可推。优先由**流水 + 期初余额自算结存**;`S7_FINISHED_ONHAND` 类快照作为校验源而非唯一来源。**目标客户 ERP 能否推流水/期初/结存,必须在阶段 0 前置验证(§10)确认——这是方案成立的前置条件,不允许留到阶段 E**。
+3. 现场不用 T8 时,按技术页 §9 既定口径:用 §6.6–6.8 业务明细把同等日期与数量算进 KPI。
+4. 演进方向(非本期):KPI 口径逐步从 `dwd_t8_*` 迁到业务明细驱动,减少对 T8 账套结构的耦合。
+
+### 7.5 乱序与迟到数据裁决
+
+推送模式没有我方 cursor,乱序不可避免。裁决优先级与三个分支定义(评审 P0-5):
+
+- **裁决依据**:行提供 `sourceVersion` 单调序号时**优先于时间戳**;否则用 `sourceUpdatedAt`(强制 ISO 8601 带时区偏移,§4.3)。
+- **新不覆盖旧**:贴源已有行的裁决依据更新 → 拒绝覆盖,行进 `rejectedRows`(`reason=stale`,按实体可配置关闭),记 `skipped_stale` 计数,不再只回 202 计数。
+- **相等分支**:裁决依据相等时按**内容哈希**决定——内容一致 → 幂等跳过;内容不同(源系统秒级精度、或部分字段变更不刷时间戳)→ 接受写入并记 `equal_ts_override` 计数,不静默拒。
+- **PULL 双源并存期**:PULL 落库时补写同语义的 `source_updated_at` 列,使双源可比较;做不到的实体在双源期**关闭守卫**并在实体配置上显式标注。
+- 转换层:std/dwd 重建以贴源当前态为准,天然最终一致;KPI 日值按 `biz_date` 幂等 upsert(已有行为)。
+- 迟到历史行(如补推上月收货):正常入贴源并触发对应 `biz_date` 的 KPI 重算窗口(前提:Rebuild 支持历史窗口,见 §7.2 待办)。
+
+### 7.6 删除语义
+
+- 单据行:`op=delete` → 贴源软删标记,转换层过滤(各 Transform 已按状态/删除标记过滤,补齐贴源标记的透传即可)。
+- 主数据:优先 `status=INACTIVE`;全量快照模式下「快照 **commit** 后未出现」的行按源端删除处理(配置开关,默认仅标记不真删;open/commit 协议与差集阈值守卫见 §4.3)。
+
+### 7.7 实体依赖与到达顺序(评审 P1-9)
+
+声明依赖图:**MDM → S1–S7;单据头 → 单据行**。
+
+- MDM 实体推送成功后,除本实体转换外触发**所有依赖模块**的重建入队(MDM 不属任何单一模块,不能只按「实体所属模块」入队)。
+- 行先于头、单据先于主数据、收货先于采购单到达时产生的**孤儿行**:默认策略「**保留待补**」——转换跳过并记 `orphan_count` 可观测指标(配置中心可见,§9),头/主数据到达后的重建自动认领;可选「丢弃」策略按实体配置。
+
+### 7.8 初始化批量装载(评审 P1-11)
+
+首日全量(三年单据行数百万级 + 库存流水)与日常推送分离:
+
+- 独立装载通道 `POST /api/mdp/inbound/{entityCode}/bulk`(NDJSON 分块上传,每块独立幂等键),**或**临时放宽 `mdp_inbound_grant.rate_limit` 并**关闭事件加急**——装载期间不逐批触发重建,装载完成后一次性触发全模块重建。
+- 装载期不承诺看板分钟级时效;装载完成后先走 §4.5 digest 全量对账,再放行日常推送。
+
+### 7.9 配置中心与菜单
+
+- 实体配置页增「入站开通」开关与推送健康指示(最近批次、成功率);数据源 `source_type` 语义扩展 `API_INBOUND`(原含糊的 `API_PUSH` 留给出站或废弃别名)。
+- 入站授权(`mdp_inbound_grant`)管理页:新登记 FUNC 编号(导航按方案 A 侧栏短标),不改已有编号;审计首期并入 `FUNC-S9-014` 任务日志按「INBOUND」筛选。
+
+---
+
+## 8. 安全设计
+
+| 层 | 措施 |
+|----|------|
+| 传输 | 强制 HTTPS;可选专线/VPN |
+| 认证 | 插件侧 `InboundSignature` HMAC-SHA256 签名(方法+路径+key+时间戳+nonce+**body 摘要**+Idempotency-Key);时间窗 ±5min;密钥只带外交付;轮换期同一对接方双 key 灰度(§4.2) |
+| 完整性 | `X-Content-SHA256`(**原始请求体字节** sha256)进签名串,服务端重算比对——防篡改而非仅防截断;禁止规范化 JSON 后哈希 |
+| 防重放 | 时间窗 + nonce 缓存短窗口去重(=时间窗×2,参照框架做法);`Idempotency-Key` 长窗口唯一索引(≥7 天,不短于对方重试策略上限)——两者**分储不混用**;P-011 清理窗口 ≥ 该值 |
+| 授权 | 凭证绑租户/机器用户(Claims 注入);`mdp_inbound_grant` 实体白名单 + 工厂绑定;body 租户与凭证不一致整批 `403` |
+| 限流 | access_key × entity 双维度;单行/单批大小上限;`413/429` 明确语义 |
+| 审计 | 每请求落 `mdp_inbound_request`(状态机、信封哈希、行数、结果、耗时、IP)+ 原始信封归档(N 天)+ `mdp_sync_log` |
+| 禁项 | 禁 `[AllowAnonymous]`;禁未登记实体落库;禁把 S0 CRUD 开放为推数口 |
+
+安全回归用例:无签/错签/过期签;body 任一字节被改 → 摘要与签名必不符(401/400);剥离或替换 `Idempotency-Key` → 签名不符 401 或缺头 400;错租户;未开通实体;超限行/批;幂等冲突(同 Key 不同体 409、处理中 409 in-progress);**入站签名凭证调任意登录态业务 API 必 401**(scheme 隔离断言,评审 P0-2);stale 覆盖拒绝且行进 rejectedRows。
+
+---
+
+## 9. 可观测与运维
+
+- **调用侧**:对方凭 `syncBatchId` 自查回执与对账摘要(§4.5)。
+- **运维侧**:配置中心入站页(每 access_key 的调用量、成功率、最近错误、限流命中);实体级「最后成功推送时间」低于阈值告警(推送模式特有——**没有调用本身就是异常**,补充现有 `MdpSourceHealthCheckJob` 以覆盖 INBOUND 源);沉默告警阈值**按实体 × 日历配置**(周末/停产计划不告警),避免周一早上一片红。孤儿行计数 `orphan_count`(§7.7)、快照滞留(open 未 commit)与差集拦截事件入配置中心。
+- **数据侧**:沿用 `mdp_sync_log` + 各模块重建日志;KPI 日值表有 `calc_time` 可核对刷新时点。
+
+---
+
+## 10. 实施阶段
+
+| 阶段 | 内容 | 完成定义 |
+|------|------|----------|
+| **0 契约可行性验证(编码不门禁,发布前必关闭)** | 用目标客户 ERP 做 **3 个实体**(建议 `S1_SALES_ORDER_ENTRY`、`MDM_ITEM`、`S5_INVENTORY_TXN`)真实字段对表,验证两层映射机制(评审 P1-7);书面确认对方能否推库存流水/期初/结存快照(评审 P1-12) | 字段对表双方会签 + T8 等价口径书面确认 = **契约冻结点,阶段 G(对外指南)前必须完成**;编码阶段不设此门禁(2026-09-10 负责人决策),WP-A 起即可按我方所需先定契约(v1-draft 草案态,字段经 `mdp_field_map` 可调)。**T8 库存口径属数据来源存在性问题,编码期间即并行确认**(一句话书面答复即可);若对方无法推流水/期初:该数据来源问题必须在契约冻结点前解决(对方补数或调整契约口径),**不做降级拉数设计**(2026-09-10 负责人决策:按最优目标态执行,不设临时过渡通道)。**责任人:立项时由负责人指定(业务方对接 + 我方集成负责人各一)**——30 实体 × 逐家字段会签是阶段 H 拖延的真实风险,会签机制在此先跑通 |
+| A 契约与授权 | `mdp_inbound_grant`、`mdp_inbound_request`(含状态机)、`mdp_inbound_envelope`、`mdp_inbound_snapshot`、`mdp_inbound_conflict`、`mdp_field_map` 建表;实体 `inbound_enabled` 扩列;`InboundSignature` scheme 与限流 | **编码完成 2026-09-10**(落地脚本 `1.0.536`,避让远端已占用的 `1.0.527`);§8 回归待库脚本 + 联调 |
+| B 接收 + Writer | Controller、请求幂等状态机与同事务、信封归档、行级 stale 守卫(三分支,§7.5);与 PULL 同行 upsert | **编码完成 2026-09-10**(落地脚本 `1.0.537`,避让远端已占用的 `1.0.528`) |
+| C 事件加急转换 | 接收→模块 `EnqueueAsync` 接线(含 MDM 触发依赖模块,§7.7);按批转换运维口;Rebuild 历史 biz_date 窗口能力核查(§7.2 待办关闭) | **编码完成 2026-09-10**(server `1.0.529`);§7.2 已核查:不支持任意历史窗口 |
+| D 主数据回灌 | `MdmMirrorUpsertService` 走 S0 实体 + SqlSugar + 冲突表;快照 open/commit 协议落地。S0 页镜像只读策略**不在本期**(前端另立项) | **编码完成 2026-09-11**(server `1.0.538`,与 E/F 同批) |
+| E T8 等价实体 | `S5_INVENTORY_TXN`、`S5_INVENTORY_OPENING_BALANCE` 等登记与转换复用(口径以阶段 0 结论为准) | **实体登记完成 2026-09-11**(v1-draft,Q7 方案 A 落 `mdp_stg_t8_kc_tz_list` / `kc_zj_list`);T8 现场口径仍待 G1 |
+| F Mock 与对内文档 | 仓库 Mock POST 脚本(含快照/bulk/partial/stale 用例);任务书 P-2 更名 `API_INBOUND` | **完成 2026-09-11**:`tools/mock/inbound/push_demo.py`;P-2 卡片已更名;digest 端点已开 |
+| G 对外指南 | 集成指南增「方式丙」+ 业务/技术详表勾稽更新 | **正文已写 2026-09-11**(指南第 4 章);G1 会签关闭前契约保持 v1-draft,现场按白名单开通 |
+| H 全量实体开通 | 按 §6 矩阵逐实体开白名单、现场会签字段;**字段语义会签挂责任人与时间盒**(每家对接方会签不超 5 个工作日,逾期升级到负责人) | 业务页「最小集 10 项」真推验收 |
+
+**阶段 0 不通过不冻结契约**(编码先行、契约保持 v1-draft 草案态;阶段 G 前未关闭不得对外发布);A–C 必须同批上线,**禁止先开无鉴权端口「联调再说」**。
+
+### 验收提纲(在旧版基础上扩展)
+
+1. 无签/错签/过期 → 401;未开通实体 → 403;body 租户不一致 → 403;**入站签名凭证调任意登录态业务 API → 401**。
+2. body 任一字节被改 → `X-Content-SHA256` 与签名必不符(401/400);缺 `Idempotency-Key` → 400。
+3. 同 `Idempotency-Key` 同 body 两次 → 第二次 replay,贴源仍一行;同键不同体 → 409;同键并发 → 一者 202 一者 409 in-progress;写库中途故障后同 Key 重试 → 不出现「半批 + 假 accepted 回执」。
+4. 同 `bizKey` 先 PULL 后 INBOUND → 一行,`source_system` 为入站源;旧 `sourceUpdatedAt` 重推 → 行进 rejectedRows(reason=stale)并计数;同时间戳不同内容 → 接受并记 `equal_ts_override`;无时区时间 → 行级拒绝。
+5. 有拒绝行时回执 `code=2`;对方 Mock 验收必须含 partial 处理用例。
+6. 推送后分钟级:`mdp_std_*` 业务键一致、`ado_s9_kpi_value_l*_day` 当日值刷新;九宫格、详情看板、诊断树、ChatBI 问答、S8 快照五端抽验通过。
+7. 主数据推送后 S0 页镜像可见且只读;评审选料正常;本库手建同编码物料被推送撞键 → 落 `mdp_inbound_conflict` 冲突表不覆盖。
+8. `s1-mdp/inbound` 仍只能内部抽数,外部带入站签调用不得被当成推数成功。
+9. 全量快照:未 commit 不产生任何删除语义;commit 后批次外缺失行按约定软删/停用;差集停用比例超阈值 → 拒绝自动执行转人工;快照 24h 未 commit 过期作废。
+10. 孤儿行(行先于头到达)→ 保留待补、`orphan_count` 可见,头到达后重建自动认领。
+11. 指南在 G 完成前仍写「一般不要求对方推」。
+
+---
+
+## 11. 跨模块影响(确认前不编码)
+
+| 波及 | 可能影响 |
+|------|----------|
+| 数据中台 | 新对外端点(inbound/snapshots/schema/bulk)、授权/审计/信封/快照/冲突/字段映射六张新表、实体开关、事件加急接线(含 MDM 依赖触发)、Writer stale 守卫(三分支) |
+| 框架 Core | **不改**:身份复用 `SysOpenAccess`;认证为插件侧独立 `InboundSignature` scheme(框架签名串为 private static 不可扩展,评审 P0-1 源码复核确认);机器用户为数据准备 |
+| 安全/网关 | 对公网或专线开放、IP 白名单、限流策略 |
+| S0 | 主数据镜像 upsert 与页面只读策略(UAT-INT-003/006 主路径落地) |
+| S1–S7 | 同一对象拉/推双源,转换幂等(Writer 按键 upsert + stale 守卫) |
+| S5–S7/S9 指标 | T8 等价实体的口径勾稽 |
+| 集成指南/Mock | 落地后才改对外表述 |
+| P-2/WP4 | 任务名与 `API_PUSH` 出站脱钩 |
+| P-023 | 不修 out,只预留凭证域 |
+
+未确认前 **不新增** `api/mdp/inbound` 控制器,**不改** PULL / Outbox / S0 行为。
+
+---
+
+## 12. 废弃与命名对照
+
+| 旧说法 | 本方案 |
+|--------|--------|
+| P-2 `API_PUSH` 入站 Webhook | `API_INBOUND` + `/api/mdp/inbound/{entityCode}` |
+| 自建 `mdp_inbound_app` 凭证表 + 自写验签 | 复用框架 `SysOpenAccess` 身份 + 插件侧 `mdp_inbound_grant` 授权 |
+| 复用框架 `SignatureAuthentication` 验签(v1) | 插件侧 `InboundSignature` scheme:签名串含 body 摘要 + Idempotency-Key(§4.2),身份仍复用 `SysOpenAccess` |
+| 信封 `contentHash`(规范化 JSON 哈希,在 body 内,v1) | `X-Content-SHA256` 头(原始字节哈希)进签名串;信封不再携带 contentHash |
+| 全量快照「批次结束自动差集」(v1) | 快照 open/commit/过期/差集阈值人工守卫(§4.3) |
+| 对外字段名 = 转换 SQL 源键(v1) | 对外规范字段 + `mdp_field_map` 两层映射(§4.3);首期规范字段 = 现有键 |
+| 对方自算 `bizKey` 必填(v1) | 服务端按 `biz_key_expr` 计算,`bizKey` 降为可选校验字段 |
+| body 租户不一致 409(v1) | 403 |
+| 「至多」归档原始 HTTP 信封(v1) | 信封归档首期必做,保留 N 天可重放(§4.4) |
+| `mdp_stg_external_row` 当业务贴源 | 不用;原始信封归档于 `mdp_inbound_envelope` 仅供重放 |
+| S0 回灌「另案再说」 | 本方案主路径(§7.3),走 S0 领域服务 + 冲突表人工裁决 |
+| T8 只读库直连 | T8 等价推送实体(§7.4),流水+期初自算结存、快照作校验源,消费端零感知 |
+| 指南「Webhook 可选、默认不做」 | 架构上为标准主方式;对外句子等落地再改 |
+
+---
+
+## 13. 本文件明确不做
+
+- 未确认不写控制器、不加匿名路由。
+- 不在入站里开工单、改评审状态。
+- 不把 S0 CRUD API 开放给第三方当推数口。
+- 不修复 `api/aidop/out` 免登(P-023)。
+- 不在首期开目录 BOM 推送(等双模 BOM 权威表)。
+
+---
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-09-06 | 初版:方式丙、与甲乙同 Writer、HMAC 凭证绑租户、请求+行双幂等、异步转换、命名与 P-2/出站脱钩 |
+| 2026-09-06 | 升级为全量推送最佳方案:定位改「标准主入站方式」;认证改复用框架 `SysOpenAccess`+签名管线、授权独立 `mdp_inbound_grant`;新增五大消费端需求基线(§2)、实体全覆盖矩阵(§6)、S0 主数据回灌主路径(§7.3)、T8 等价实体(§7.4)、乱序 stale 守卫(§7.5)、对账 API(§4.5)、推送沉默告警(§9) |
+| 2026-09-10 | **v2 按[评审](./针对第三方标准API推数入站方案的评审.md)修订**(复核结论见评审文末):§4.2 认证改插件侧 `InboundSignature` scheme,签名串含原始 body 字节摘要 + `Idempotency-Key`(Core 零改动),同一对接方多 key 轮换,租户不一致 409→403,工厂维度校验明确;§4.3 快照改 open/commit/过期/差阈值守卫、`sourceUpdatedAt` 强制 ISO 8601 带偏移、可选 `sourceVersion` 优先、`bizKey` 改服务端计算、对外规范字段 + `mdp_field_map` 两层映射;§4.4 `Idempotency-Key` 必填 + RECEIVED→COMMITTED/FAILED 状态机与同事务、有拒绝行 `code=2`、信封归档首期必做;§7.2 更正事件加急为 DB 持久化队列(复核结论)并留历史窗口待办;§7.3 镜像走 S0 领域服务 + 冲突表;§7.4 增期初余额实体、流水自算结存优先;§7.5 stale 三分支裁决;新增 §7.7 实体依赖图与孤儿行策略、§7.8 初始化批量装载;§8 nonce 与 Key 分储、完整性行改写、补 scheme 隔离回归用例;§9 沉默告警按实体×日历;§10 增阶段 0(3 实体字段对表 + T8 可行性,挂责任人与时间盒);§6 矩阵增 `S5_INVENTORY_OPENING_BALANCE`/`S7_FINISHED_OPENING_BALANCE` |
+| 2026-09-10 | 阶段 0 定位调整(负责人决策):**编码不设 ERP 会签门禁**,按我方所需先定标准契约(v1-draft 草案态);契约冻结点移至**阶段 G 前**——3 实体对表会签 + T8 库存口径书面确认在此前完成即可;T8 库存口径为数据来源存在性问题,编码期间并行确认。[执行任务书](./第三方标准API推数入站执行任务书.md) §1 门禁同步改写 |
+| 2026-09-10 | 总体方针(负责人决策):**按最优目标态执行,不做临时过渡设计**——剔除阶段 0/门禁中的「降级方式甲/乙拉数补全」表述;切换 INBOUND 即停用对应 PULL 任务,不做双源并存设计;任务书 §11 实施决策 D-1~D-4 确认(见其 §16 G2 记录) |
+| 2026-09-11 | §10 阶段 A–F 编码完成日期回写(A–C:2026-09-10;D/E/F:2026-09-11,server `1.0.538`)。P-2 路径更正为 `/api/mdp/inbound/{entityCode}` |
+| 2026-09-11 | 阶段 G 指南正文:集成指南第 4 章按全部完成后的方式丙写入。阶段 H(全量实体开通)与 G1 会签见待办 **P-046** |

BIN
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.docx


+ 40 - 6
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md

@@ -3,9 +3,9 @@
 | 项 | 内容 |
 |----|------|
 | 读者 | 业务负责人、计划 / 采购 / 仓储 / 质量 / 生产 impl 对接窗口 |
-| 配套 | 技术落表见 [`Ai-DOP第三方对接业务数据详表-技术页.md`](./Ai-DOP第三方对接业务数据详表-技术页.md);对接方式见 [`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md) |
+| 配套 | 技术落表见 [`Ai-DOP第三方对接业务数据详表-技术页.md`](./Ai-DOP第三方对接业务数据详表-技术页.md);对接方式见 [`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md)(**方式丙见该指南第 4 章**) |
 | 目的 | 讲清楚:**要让九宫格、模块详情看板、智慧诊断、ChatBI 能用,对方系统必须提供哪些业务数据** |
-| 日期 | 2026-09-04 |
+| 日期 | 2026-09-11(补方式丙 `entityCode` 勾稽;数据包与必接列仍按 2026-09-04) |
 
 本页不写库表名。技术同事按「业务数据包」到技术页对字段。
 
@@ -29,6 +29,40 @@
 
 「必接」= 没有则对应格子/诊断基本不可用。「建议」= 详情列表、名称展示、依据更完整。
 
+### 0.1 怎么送到 Ai-DOP(三种入站,数据包相同)
+
+| 方式 | 谁主动 | 何时用 |
+|------|--------|--------|
+| **丙 · 标准 API 推送** | 对方 `POST` 我方 | **全部数据由对方系统推送时的主方式**(指南第 4 章) |
+| 甲 · 库同步 | 我方抽数 | 对方只能给只读库 |
+| 乙 · API 拉取 | 我方 GET | 对方只给查询接口 |
+
+同一数据包三种方式要的**业务内容相同**。走丙时,对接编码用下表 `entityCode`(未开通返回 403)。主数据走丙时会回灌运营建模页(手建同编码不覆盖);S0 页改成只读另做。
+
+| 业务数据包 | 方式丙 entityCode |
+|------------|-------------------|
+| 物料 | `MDM_ITEM` |
+| 客户 | `MDM_CUSTOMER` |
+| 供应商 | `MDM_SUPPLIER` |
+| 货源清单 | `MDM_SOURCE_LIST` |
+| 库位 | `MDM_LOCATION` |
+| 员工 / 岗位人数 | `MDM_EMPLOYEE_HEADCOUNT` |
+| 销售订单头 / 行 | `S1_SALES_ORDER` / `S1_SALES_ORDER_ENTRY` |
+| 合同评审(含环节起止) | `S1_CONTRACT_REVIEW` |
+| 发货计划 / 发货单 ASN | `S1_SHIPPING_PLAN` / `S1_ASN_SHIPPER` |
+| 生产工单 | `S2_WORK_ORDER` |
+| 日计划 / 排产结果 | `S2_OPERATION_SCHEDULE` / `S2_SCHEDULE_RESULT` |
+| 物料需求 / 采购申请 / 采购订单 / 交货计划 / 齐套存量 | `S3_SUPPLY_DEMAND` / `S3_PURCHASE_REQUEST` / `S3_PURCHASE_ORDER` / `S3_DELIVERY_SCHEDULE` / `S3_INVENTORY_ONHAND` |
+| 交货收货 / 供应商发货 / 退货 / IQC 收货行 / 欠料 | `S4_RECEIPT_CONFIRM` / `S4_SUPPLIER_SHIPMENT` / `S4_PURCHASE_RETURN` / `S4_IQC_RECEIPT` / `S4_SHORTAGE` |
+| 采购收货 / 领料上线 / 退料 / 委外发料 / IQC 结论 | `S5_PURCHASE_RECEIPT` / `S5_PRODUCTION_ISSUE` / `S5_PRODUCTION_RETURN` / `S5_OUTSOURCE_ISSUE` / `S5_IQC_RESULT` |
+| 出入库流水 / 库存期初 | `S5_INVENTORY_TXN` / `S5_INVENTORY_OPENING_BALANCE` |
+| 车间报工 / 过程检验 / 产线运行 | `S6_REPORT` / `S6_IPQC_INSPECTION` / `S6_LINE_RUNTIME` |
+| 成品入库 / FQC / 质量退货 | `S7_PRODUCTION_RECEIPT` / `S7_FQC_RESULT` / `S7_QUALITY_RETURN` |
+| 成品结存 / 成品期初 | `S7_FINISHED_ONHAND` / `S7_FINISHED_OPENING_BALANCE` |
+| 销售发货 | 复用 `S1_ASN_SHIPPER` |
+
+周转类格子:优先推 **流水 + 期初**(结存由中台自算);结存快照作校验。目录 BOM 首期不开。
+
 ---
 
 ## 1. 主数据(先接,后面单据才能对上名称)
@@ -160,7 +194,7 @@
 |------------|----------------|------------|------|
 | **采购收货** | 收货单号+行、物料、实收数量、收货日期 | 供应商、采购单、批次、库位 | 宫详诊 |
 | **生产领料 / 上线** | 领料单、工单、物料、实发数量、发到产线日期 | 计划开工日(用于满足率) | 宫详诊 |
-| **库存现存量 / 进出存** | 物料、数量、出入库日期、出入类型 | 库位、批次、金额或单价 | 宫详 |
+| **库存现存量 / 进出存** | 物料、数量、出入库日期、出入类型;周转还要约定期初 | 库位、批次、金额或单价;结存快照作校验 | 宫详 |
 | **来料检验(IQC)** | 检验单号、结论、检验日期、物料 | 不合格数量 | 诊 |
 | **委外发料、生产退料** | 单据号、物料、数量、日期 | — | 详 |
 
@@ -189,7 +223,7 @@
 | **成品检验(FQC)** | 报检日期(订单行最早报检)、结论 | 检验单号、明细 | 宫详诊 |
 | **销售发货** | 订单行、实发数量、实际发货日期 | 与「确认交期」对比算出满足率 | 宫详诊问 |
 | **质量退货** | 因质量退回的数量、对应发货数量 | 退货日期、订单 | 宫问 |
-| **成品库存** | 物料结存数量(或金额)、日期 | 用于成品周转、全库存周转 | 宫问 |
+| **成品库存** | 成品期初 + 进出(或结存快照作校验)、日期 | 用于成品周转、全库存周转 | 宫问 |
 
 S8 异常数量/处理时效:一般**不用第三方单独送「异常单」**,由本系统监控规则生成。
 
@@ -222,6 +256,6 @@ S8 异常数量/处理时效:一般**不用第三方单独送「异常单」**
 - [ ] 对方系统有这笔业务  
 - [ ] 能提供「必须」列  
 - [ ] 由哪套系统出(ERP / MES / WMS / QMS / SRM)  
-- [ ] 用数据库给,还是用接口给(技术页再填字段)  
+- [ ] 用数据库给、用查询接口给,还是**调用我方标准 API 推送**(丙;技术页填 `entityCode` 与字段)  
 
-勾完交给实施,按技术页对栏位。
+勾完交给实施,按技术页对栏位。走丙时每行还须带带时区的源更新时间(技术页公共列)。

BIN
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.docx


+ 35 - 28
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md

@@ -3,18 +3,24 @@
 | 项 | 内容 |
 |----|------|
 | 读者 | 第三方开发 / 实施、Ai-DOP 数据中台配置 |
-| 配套 | 业务含义见 [`Ai-DOP第三方对接业务数据详表-业务页.md`](./Ai-DOP第三方对接业务数据详表-业务页.md);协议见 [`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md) |
+| 配套 | 业务含义见 [`Ai-DOP第三方对接业务数据详表-业务页.md`](./Ai-DOP第三方对接业务数据详表-业务页.md);协议见 [`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md)(**方式丙第 4 章**,对象清单第 5 章) |
 | 范围 | 保证九宫格、模块详情看板、智慧诊断、ChatBI 能算数、能下钻的**落表栏位** |
-| 日期 | 2026-09-04 |
+| 日期 | 2026-09-11(补丙通道 `entityCode`、公共契约列、S0 镜像与 T8 推送等价;源字段仍按现网转换键) |
 
 约定:
 
-- **源字段**:对方库列名或 API JSON 键(现网转换按此读取,大小写不敏感)  
-- **贴源**:先入 `mdp_stg_*`(整行 JSON)  
+- **源字段**:对方库列名、乙的 API JSON 键,或丙的**对外规范字段**(现网转换按此读取,大小写不敏感)。丙可用 `GET /api/mdp/inbound/{entityCode}/schema` 取清单;列名不同走 `mdp_field_map`,勿改转换 SQL  
+- **贴源**:先入 `mdp_stg_*`(整行 JSON);甲/乙/丙落同一张表  
 - **标准/宽表**:转换写入的栏位,看板与诊断读这里;ChatBI 只读日度 KPI 表  
 - **必** = 对应指标或依据算不出来;**建** = 详情展示、名称、过滤  
+- **方式丙 entityCode**:与业务页 §0.1 同一张对照表,路径 `/api/mdp/inbound/{entityCode}`(不带 `/v1/`)  
 
-公共列每张贴源都要能落到:`tenant_id`、增量时间(`UpdateTime`/`update_time`/`updateTime`)、业务键(见集成指南第 4 章)。
+公共列:
+
+| 通道 | 必须落到贴源的列 |
+|------|------------------|
+| 甲 / 乙 | `tenant_id`、增量时间(`UpdateTime` / `update_time` / `updateTime`)、业务键(见集成指南**第 5 章**) |
+| **丙** | 上列业务键由服务端按 `biz_key_expr` 计算(对方可附 `bizKey` 校验);每行必填 **`sourceUpdatedAt`(ISO 8601 带时区)**;可选 `sourceVersion`、`op` |
 
 ---
 
@@ -31,7 +37,7 @@
 | 物料类型 | `ItemType` / `item_type` | 建 | `mdp_std_item.item_type` |
 | 状态 | `Status` / `is_active` | 建 | `mdp_std_item.status` |
 
-业务键:`Domain,ItemNum`。S0 页面表 `ItemMaster` 与此同源,中台**不会自动回灌** S0 页(见 UAT-INT-003)。
+业务键:`Domain,ItemNum`。方式丙 `MDM_ITEM` 推送成功后**会镜像** `ItemMaster`(手建同行且无来源标记不覆盖,落冲突表;WMS 共管库位/货架/安全库存/批次策略更新时跳过)。甲/乙抽数仍不自动回灌 S0 页。S0 页只读化见 P-046。
 
 ### 1.2 供应商 → `mdp_stg_supplier` → `mdp_std_supplier`
 
@@ -43,7 +49,7 @@
 | 启用 | `IsActive` | 建 | `status`(ACTIVE/INACTIVE) |
 | 联系人/地址/币种 | `contact` / `address` / `Curr` | 建 | `contact` / `address` / `currency_type` |
 
-业务键:`Domain,Supp`。
+业务键:`Domain,Supp`。方式丙 `MDM_SUPPLIER` 同样镜像 `SuppMaster`(手建不覆盖)。
 
 ### 1.3 客户 → `mdp_stg_customer`(及订单头上的客户列)
 
@@ -53,7 +59,7 @@
 | 客户名称 | `SortName` / `custom_name` | 建 | `mdp_std_so.customer_name` |
 | 国家 | `country` | 建 | `mdp_std_so.country` |
 
-业务键:`Domain,Customer`。
+业务键:`Domain,Customer`。方式丙 `MDM_CUSTOMER` 镜像 `CustMaster`(手建不覆盖)。
 
 ### 1.4 货源清单 → `mdp_stg_source_list` → `mdp_std_supplier_item`
 
@@ -68,7 +74,7 @@
 
 ## 2. S1 产销协同
 
-### 2.1 销售订单头 `crm_seorder` → `mdp_stg_so` → `mdp_std_so`
+### 2.1 销售订单头 `crm_seorder` → `mdp_stg_so` → `mdp_std_so`(丙:`S1_SALES_ORDER`)
 
 | 业务数据 | 源字段 | 必/建 | `mdp_std_so` |
 |----------|--------|:----:|--------------|
@@ -81,7 +87,7 @@
 | 关闭 | `closed` | 建 | `order_status` / `closed` |
 | 流程状态 | `flowstate` | 建 | `flow_state` |
 
-### 2.2 销售订单行 `crm_seorderentry` → `mdp_std_so`(一行一条)
+### 2.2 销售订单行 `crm_seorderentry` → `mdp_std_so`(一行一条;丙:`S1_SALES_ORDER_ENTRY`)
 
 | 业务数据 | 源字段 | 必/建 | `mdp_std_so` |
 |----------|--------|:----:|--------------|
@@ -266,7 +272,7 @@
 
 `FBILLNO` 结论、日期、物料 → 诊断质量依据(直接读业务表/标准层)。
 
-现网 S5–S7 **指标**还可走 T8 只读(见 §9),与本节省业务明细并行,不要混成一张表。
+S5–S7 **指标**还可走 T8 等价通道(见 §9):优先方式丙推流水/期初,其次甲只读。与本节省业务明细并行,不要混成一张表。
 
 ---
 
@@ -322,20 +328,20 @@
 
 ---
 
-## 9. T8 / ERP 指标账(S5–S7 九宫格现网常用)
+## 9. T8 / ERP 指标账(S5–S7 九宫格)
 
-只读 SQL Server,转换进 `dwd_t8_*` 再进 `ado_s9_kpi_value_l1_day`。
+转换进 `dwd_t8_*` 再进 `ado_s9_kpi_value_l1_day`。**全量推送现场优先方式丙**(拍平行落既有 `mdp_stg_t8_*`,KPI SQL 不改);无推送条件时再甲(只读 SQL Server)。
 
-| 业务含义 | T8 源表 | 转换产物(看板读) |
-|----------|---------|-------------------|
-| 出入库流水 | `kc_tz_head` `kc_tz_list` | `dwd_t8_material_online_*` `dwd_t8_material_inventory_turnover` 等 |
-| 库存单据 | `kc_dd_head` `kc_dd_list` `kc_dd_list_cllist` | 上线/周转/人效 |
-| 质检流水 | `kc_zj_list` | 质量相关分解 |
-| 报工头 | `Cj_Bg_Head_Rep` | `dwd_t8_work_order_mfg_*`;与 §7.1 可同源 |
-| 人员 | `sys_pelist` | 人效分母 |
-| 发货/效率 | 同上库存+发货口径 | `dwd_t8_order_shipment_*` `dwd_t8_finished_warehouse_efficiency` |
+| 业务含义 | 方式丙 entityCode | 甲源表(T8) | 贴源 / 看板读 |
+|----------|-------------------|--------------|---------------|
+| 出入库流水 | `S5_INVENTORY_TXN` | `kc_tz_head` `kc_tz_list` | `mdp_stg_t8_kc_tz_list` → `dwd_t8_material_*` |
+| 库存期初 | `S5_INVENTORY_OPENING_BALANCE` | 同上 list(`source_table` 隔离) | 同分表;结存由期初+流水自算 |
+| 成品结存(校验) | `S7_FINISHED_ONHAND` | `kc_zj_list` 等 | `mdp_stg_t8_kc_zj_list` → `dwd_t8_finished_*` |
+| 成品期初 | `S7_FINISHED_OPENING_BALANCE` | 同上 | 同分表 |
+| 雇员 / 人效分母 | `MDM_EMPLOYEE_HEADCOUNT` | `sys_pelist` / `EmployeeMaster` | `mdp_stg_employee` → 镜像 `EmployeeMaster` |
+| 库存单据 / 报工头 | 仍可用甲,或并入流水/报工实体 | `kc_dd_*` / `Cj_Bg_Head_Rep` | `dwd_t8_*`;报工与 §7.1 可同源 |
 
-现场若不用 T8,须用 §6–§8 业务明细把同等日期和数量算进 KPI,否则 S5–S7 格子会空。
+现场既无 T8 库、又不能推流水/期初,须用 §6–§8 业务明细把同等日期和数量算进 KPI,否则 S5–S7 格子会空。切换为丙后停用该对象抽数任务。
 
 ---
 
@@ -349,7 +355,7 @@
 | 智慧诊断依据 | `mdp_stg_so`(评审流)、`dwd_order_schedule_trans`、`dwd_supplier_delivery`、`dwd_material_readiness`、`mdp_std_s6_report`、`qms_qcp_inspbill`、`mdp_std_fqc_result`、`mdp_std_purchase_*` 等 | 未登记的新表 |
 | ChatBI | **仅** `ado_s9_kpi_value_l1~l4_day` | `dwd_*`、第三方、原始明细 |
 
-因此:第三方数据必须先入贴源并跑模块 refresh,**禁止**让看板直连对方接口。
+因此:第三方数据必须先入贴源并跑模块 refresh(丙在贴源成功后会加急入队),**禁止**让看板直连对方接口。不要把 `POST /api/AidopKanban/s*-mdp/inbound` 当成推数口。
 
 ---
 
@@ -357,10 +363,11 @@
 
 每个业务数据包请技术同时交付:
 
-1. 源表或 API 路径(集成指南第 4 章)  
-2. 本页「源字段」是否都能取到(缺列用视图补齐)  
-3. 增量列、业务键样例一行  
-4. 抽数后 `mdp_std_*` / `dwd_*` 对应栏位非空  
-5. 跑 `s*-mdp/refresh` 后 `ado_s9_kpi_value_l1_day` 该模块有当日值  
+1. 入站方式:甲源表 / 乙 GET 路径(指南第 3、5 章) / **丙 `entityCode`(指南第 4 章 + 业务页 §0.1)**  
+2. 本页「源字段」是否都能取到(甲缺列用视图;丙列名不同登记映射,先 `GET .../schema`)  
+3. 甲/乙:增量列、业务键样例一行。丙:带时区的 `sourceUpdatedAt` 样例 + 服务端算出的 `bizKey`  
+4. 入站后 `mdp_std_*` / `dwd_*` 对应栏位非空  
+5. 模块 refresh(或丙回执 `transformEnqueued`)后 `ado_s9_kpi_value_l1_day` 该模块有当日值  
+6. 丙额外:partial(`code=2`)处理、同 Key 重放、主数据快照 open/commit  
 
 字段级签收以本页为准;业务是否该接、优先级以业务页为准。

BIN
doc/plan/系统集成/Ai-DOP第三方系统集成指南.docx


+ 257 - 51
doc/plan/系统集成/Ai-DOP第三方系统集成指南.md

@@ -3,8 +3,8 @@
 | 项 | 内容 |
 |----|------|
 | 读者 | ERP / MES / WMS / QMS / SRM 等第三方系统厂商与实施人员 |
-| 目的 | 说明要对哪些**业务数据**对接,以及 **数据库直接同步**、**API** 两种方式分别怎么做 |
-| 版本 | 2026-09-04(按当前数据中台配置整理) |
+| 目的 | 说明要对哪些**业务数据**对接;入站三种方式(库同步 / 我方拉取 / **对方推送我方标准 API**)与回写分别怎么做 |
+| 版本 | 2026-09-11(按方式丙全部完成后的对外契约整理;前版 2026-09-04) |
 | 范围 | 入站(第三方 → Ai-DOP)与回写(Ai-DOP → 第三方) |
 
 本文只讲对接约定,不包含数据库密码、Token、连接串等凭据。凭据须走现场带外方式交付。
@@ -23,33 +23,39 @@
         │
         │  方式甲:开放只读库,Ai-DOP 定时抽数
         │  方式乙:提供查询 API,Ai-DOP 定时拉取
+        │  方式丙:调用我方标准 API 推送(推荐主方式)
         ▼
 Ai-DOP 贴源层 → 标准层 → 宽表 / 日度指标
         │
         ├─ 九宫格看板
         ├─ 各模块详情看板
         ├─ 智慧诊断
+        ├─ S8 监控(读指标与标准层,不要求对方送异常单)
         └─ ChatBI(目前只问指标,不问原始明细)
 ```
 
+**全部业务数据都由第三方系统提供时,优先走方式丙。** 甲、乙仅用于对方暂时不能推送、或迁移期对照联调。三种方式写入同一张贴源表,再走同一套标准层转换,看板与诊断零改动。
+
 ### 1.2 同一业务对象只需选一种入站方式
 
-每个业务对象(例如「销售订单」「采购收货」)在 Ai-DOP 里可以登记成:
+每个业务对象(例如「销售订单」「采购收货」)在 Ai-DOP 里登记为下列之一:
 
-- **数据库直接同步**(`source_type = DB`)
-- **API 拉取**(`source_type = API`)
+- **数据库直接同步**(方式甲,`source_type = DB`)
+- **API 拉取**(方式乙,`source_type = API`)
+- **标准 API 推送**(方式丙,`API_INBOUND`,对方 `POST` 我方)
 
-两条路最终写入同一张贴源表,再走同一套标准层转换。  
-现场按对方能力二选一即可,不必两种都做。若两种都提供,联调时可用于对照校验。
+三条路最终写入同一张贴源表,再走同一套标准层转换。  
+现场按对象选一种即可。迁移期可短暂双开对照,稳定后**切换为丙必须停用该对象的甲/乙任务**,不要长期双源并存。
 
 ### 1.3 谁拉、谁推
 
 | 方向 | 谁主动 | 典型场景 |
 |------|--------|----------|
-| **入站** | **Ai-DOP 来拉** | 订单、工单、收货、库存、报工、检验等 |
+| **入站 · 丙** | **第三方 POST 我方** | 全量推送模式下的订单、工单、收货、库存、报工、检验、主数据 |
+| **入站 · 甲/乙** | **Ai-DOP 来拉** | 对方只能开放只读库或查询 API 时 |
 | **回写** | **Ai-DOP 来推** | 订单评审结果、排程下达、交货关闭、IQC/IPQC/FQC 结果等 |
 
-第三方一般不需要主动往 Ai-DOP 推数据(Webhook 入站为可选能力,默认不要求)。
+`GET /api/aidop/out` 是对方**只读我们的 KPI**,不是入站。`POST /api/AidopKanban/s*-mdp/inbound` 是内部抽数开关,**不要**当推数口。
 
 ---
 
@@ -63,7 +69,7 @@ Ai-DOP 贴源层 → 标准层 → 宽表 / 日度指标
 1. 提供只读账号,权限限于本指南列出的业务表(或等价视图)。
 2. 开通网络:Ai-DOP 应用服务器能访问该库的主机、端口。
 3. 每张表必须有:
-   - **业务主键**(见第 4 节「业务键」列,可用视图拼出)
+   - **业务主键**(见第 5 节「业务键」列,可用视图拼出)
    - **增量时间字段**(建议 `UpdateTime` / `update_time`,类型 `datetime`)
 4. 建议提供 `tenant_id` / `TenantId`(或工厂/账套字段),便于多租户隔离。
 5. 不要求与 Ai-DOP 表名完全一致:可提供**视图**,把贵司字段映射成下表「建议源表/视图列」。
@@ -114,7 +120,7 @@ SELECT ... FROM <源表或视图>
 |----|------|
 | 增量列 | 业务更新时必须刷新;禁止只改数据不改时间 |
 | 删除 | 物理删除无法增量感知;请用状态字段(如 `IsDeleted`/`Status`)软删 |
-| 主键 | 稳定、不复用;业务键与第 4 节一致 |
+| 主键 | 稳定、不复用;业务键与第 5 节一致 |
 | 字符集 | UTF-8 |
 | 网络 | 白名单放行 Ai-DOP 应用服务器 IP |
 | 性能 | 增量列建索引 |
@@ -169,7 +175,7 @@ Authorization: Bearer {token}
 |----|------|
 | 列表路径 | 默认 `data.list`(可按对象改配置) |
 | 去重键 | 每行必须有 `bizKey`(或配置的等价字段) |
-| 业务键字段 | 必须带上第 4 节「业务键」中的源字段,供 Ai-DOP 拼键去重 |
+| 业务键字段 | 必须带上第 5 节「业务键」中的源字段,供 Ai-DOP 拼键去重 |
 | 更新时间 | 建议每行带 `updateTime` |
 | HTTP 成功 | `2xx`;非 2xx 视为本批失败,会重试 |
 
@@ -207,7 +213,7 @@ Content-Type: application/json
 
 - 返回 **HTTP 2xx** 视为成功;否则 Ai-DOP 会按发件箱重试。
 - 必须按 **幂等键** 去重(同一业务单重复推送不得重复落账)。
-- 具体 `path` 与报文见第 5 节。未约定的动作,默认路径为 `/outbox/{动作编码}`。
+- 具体 `path` 与报文见第 6 节。未约定的动作,默认路径为 `/outbox/{动作编码}`。
 
 ### 3.3 联调 Mock(可选)
 
@@ -223,7 +229,185 @@ Authorization: Bearer {约定 Token}
 
 ---
 
-## 4. 要对哪些业务数据对接
+## 4. 方式丙:调用我方标准 API 推送(`API_INBOUND`)
+
+适合:**全部业务数据由贵司系统主动推送**,不开放只读库、也不提供给我方拉取的查询接口。  
+这是全量推送模式下的**标准主入站方式**。路径不带 `/v1/`:`/api/mdp/inbound/{entityCode}`。
+
+我方为每个「对接方 × 系统」签发 `AccessKey` / `AccessSecret`(只带外交付一次),凭证绑定租户。贵司用签名头调用;未开通的对象返回 403。
+
+### 4.1 统一入口与配套接口
+
+所有接口同一套签名头。`{entityCode}` 大小写不敏感,取值见第 5 节对象与下表对照。
+
+| 方法 | 路径 | 用途 |
+|------|------|------|
+| `POST` | `/api/mdp/inbound/{entityCode}` | 推送本对象一批行 |
+| `GET` | `/api/mdp/inbound/{entityCode}/schema` | 取对外规范字段(JSON Schema) |
+| `GET` | `/api/mdp/inbound/receipts/{syncBatchId}` | 按批次查回执 |
+| `GET` | `/api/mdp/inbound/{entityCode}/digest?date=yyyy-MM-dd` | 日终对账(也认 `bizDate`) |
+| `POST` | `/api/mdp/inbound/{entityCode}/snapshots` | 开启全量快照 |
+| `POST` | `/api/mdp/inbound/{entityCode}/snapshots/{snapshotId}/commit` | 提交快照差集;超阈值后可带 `?force=true` |
+| `POST` | `/api/mdp/inbound/{entityCode}/bulk` | NDJSON 分块装载(每行独立幂等键) |
+
+不要调用:`POST /api/AidopKanban/s*-mdp/inbound`(内部抽数)、`GET /api/aidop/out`(只读 KPI)、S0 维护页 CRUD。
+
+### 4.2 签名(每次请求必带)
+
+```http
+X-Access-Key:       {accessKey}
+X-Timestamp:        {unix 秒}
+X-Nonce:            {单次随机串}
+Idempotency-Key:    {请求级幂等键,必填}
+X-Content-SHA256:   {sha256(原始请求体字节) 的小写 hex}
+X-Signature:        Base64( HMAC-SHA256(secret, 签名串) )
+```
+
+签名串(逐字节拼接,中间无空格):
+
+```text
+{METHOD}&{path}&{accessKey}&{timestamp}&{nonce}&{contentSha256}&{idempotencyKey}
+```
+
+| 项 | 约定 |
+|----|------|
+| `METHOD` | 实际 HTTP 方法大写:接收数据为 `POST`,查 schema / receipts / digest 为 `GET` |
+| `path` | 仅路径,如 `/api/mdp/inbound/S1_SALES_ORDER_ENTRY`,**不含** query |
+| body 哈希 | 对**原始字节**做 SHA-256,禁止先格式化再哈希;空 body(open/commit/GET)对空字节哈希 |
+| 时间窗 | ±5 分钟;超出 → 401 |
+| nonce | 短窗口内不可重复(约 10 分钟);重放 → 401 |
+| `Idempotency-Key` | **必填**;缺失 → 400。同 Key 同 body → 返回上次 202 回执(`idempotentReplay=true`);同 Key 不同 body → 409 |
+
+中间环节改 body 任一字节,只要不同时改 `X-Content-SHA256` 与签名,即 400 `content digest mismatch`。
+
+### 4.3 推送报文
+
+推荐信封(也允许根数组):
+
+```json
+{
+  "data": {
+    "snapshotId": null,
+    "seq": null,
+    "list": [
+      {
+        "bill_no": "SO-20260901-001",
+        "entry_seq": "10",
+        "seorder_id": 900001,
+        "qty": 12,
+        "sourceUpdatedAt": "2026-09-01T10:00:00+08:00",
+        "sourceVersion": 1234567,
+        "op": "upsert"
+      }
+    ]
+  }
+}
+```
+
+| 项 | 约定 |
+|----|------|
+| 列表 | `data.list`,或根 JSON 数组 |
+| 字段名 | 对外用规范字段;可用 `GET .../schema` 取清单。贵司列名不同时由我方登记映射,**转换层不改** |
+| `bizKey` | **我方按业务键字段计算**(多字段用 `#` 连接,规则同第 5 节)。对方可附带作校验,不一致则该行拒绝 |
+| `sourceUpdatedAt` | **必填**,ISO 8601 **必须带时区**(`+08:00` 或 `Z`);无偏移则该行拒绝 |
+| `sourceVersion` | 可选;提供时优先于时间戳做乱序裁决 |
+| `op` | 默认 `upsert`。`delete` 仅部分对象开放(未开放则整行拒绝) |
+| 租户 | 以凭证绑定租户为准。行内若带 `tenant_id` 且不一致 → **整批 403** |
+| 行数 / 大小 | 默认每请求 ≤500 行、单行 ≤64KB;超出 413 |
+| 限流 | 默认每 AccessKey 60 次/分;超限 429 |
+
+乱序:同一业务键,旧 `sourceUpdatedAt` 重推进入 `rejectedRows`(`reason=stale`),不覆盖新行。同时间戳不同内容会接受并计数。
+
+### 4.4 回执(必须处理 `code=2`)
+
+贴源写入成功即返回,**不等待**看板刷新(目标分钟级可见)。
+
+```http
+HTTP/1.1 202 Accepted
+```
+
+```json
+{
+  "code": 2,
+  "message": "partial",
+  "data": {
+    "entityCode": "S1_SALES_ORDER_ENTRY",
+    "accepted": 498,
+    "rejected": 2,
+    "rejectedRows": [{ "bizKey": "...", "reason": "missing biz key fields" }],
+    "idempotentReplay": false,
+    "syncBatchId": "INB_20260911_000123",
+    "transformEnqueued": true,
+    "staleRejected": 0
+  }
+}
+```
+
+| HTTP | `code` | 含义 |
+|------|--------|------|
+| 202 | 0 | 整批接受 |
+| 202 | 2 | **部分接受**(`rejectedRows` 有行)。客户端只看 2xx 会静默丢数,验收必须含本用例 |
+| 400 | — | 缺幂等键、摘要不符、JSON 非法、缺 `seq`、快照过期、序号缺口 |
+| 401 | — | 签名 / 时间窗 / nonce / 未知 Key |
+| 403 | — | 对象未开通、租户不一致 |
+| 409 | — | 同 Key 不同体、请求进行中、快照已占用、差集超 20% |
+| 413 | — | 行数或单行过大 |
+| 404 | — | 回执批次不属于本 AccessKey |
+
+日终对账:`GET .../digest?date=yyyy-MM-dd`。`bizKeyDigest` = 当日已提交批次在贴源中的业务键,按字典序升序、用换行拼接后做 SHA-256(小写 hex)。`rejected` 为独立计数。
+
+### 4.5 全量快照(主数据 / 库存结存建议走此协议)
+
+1. `POST .../snapshots` → 得到 `snapshotId`,24 小时内有效。同一对象同时只能有一个 OPEN 快照,否则 409。
+2. 推数时在 `data` 上带 `snapshotId` 与从 1 起连续的 `seq`。跳号 → 400。
+3. `POST .../snapshots/{snapshotId}/commit`:**只有 commit 后**才把本快照未出现的旧行软标记删除。未 commit 或过期**不产生任何删除**。
+4. 缺失行超过该源该对象贴源总行数的 **20%** → 409,状态 `DIFF_BLOCKED`,不执行停用。人工核对后带 `?force=true` 再 commit。
+
+初始化大批量可用 `POST .../bulk`:请求体为 NDJSON,每行是一份合法推送信封,并带根级 `idempotencyKey`;整包仍只签一次名。
+
+### 4.6 推送对象编码(entityCode)
+
+现场按白名单开通。未开通 → 403。规范字段以 `GET .../schema` 为准。目录 BOM(`MDM_BOM_*`)首期不开。
+
+| entityCode | 业务对象 | 说明 |
+|------------|----------|------|
+| `MDM_ITEM` | 物料 | 推送后回灌 S0 `ItemMaster`(手建同行不覆盖,记冲突) |
+| `MDM_CUSTOMER` | 客户 | 回灌 `CustMaster` |
+| `MDM_SUPPLIER` | 供应商 | 回灌 `SuppMaster` |
+| `MDM_LOCATION` | 库位 | 回灌 `LocationMaster` |
+| `MDM_EMPLOYEE_HEADCOUNT` | 雇员 / 岗位人数 | 回灌 `EmployeeMaster`;贴源 `mdp_stg_employee` |
+| `MDM_SOURCE_LIST` | 货源清单 | 先于采购单据 |
+| `S1_SALES_ORDER` | 销售订单头 | 与行一起推,否则评审周期缺头日期 |
+| `S1_SALES_ORDER_ENTRY` | 销售订单行 | 键:`bill_no` + `entry_seq` |
+| `S1_CONTRACT_REVIEW` | 合同评审(含环节起止) | 诊断依据 |
+| `S1_SHIPPING_PLAN` / `S1_ASN_SHIPPER` | 发货计划 / ASN | |
+| `S2_WORK_ORDER` | 生产工单 | |
+| `S2_OPERATION_SCHEDULE` / `S2_SCHEDULE_RESULT` | 日计划 / 排产结果 | |
+| `S3_SUPPLY_DEMAND` / `S3_PURCHASE_REQUEST` / `S3_PURCHASE_ORDER` / `S3_DELIVERY_SCHEDULE` / `S3_INVENTORY_ONHAND` | 供应协同 | |
+| `S4_RECEIPT_CONFIRM` / `S4_SUPPLIER_SHIPMENT` / `S4_PURCHASE_RETURN` / `S4_IQC_RECEIPT` / `S4_SHORTAGE` | 采购执行 | |
+| `S5_PURCHASE_RECEIPT` / `S5_PRODUCTION_ISSUE` / `S5_PRODUCTION_RETURN` / `S5_OUTSOURCE_ISSUE` / `S5_IQC_RESULT` | 来料仓储 / IQC | |
+| `S5_INVENTORY_TXN` | 出入库流水 | T8 等价;拍平行落库存流水贴源 |
+| `S5_INVENTORY_OPENING_BALANCE` | 库存期初 | 与流水同分表、源表隔离 |
+| `S6_REPORT` / `S6_IPQC_INSPECTION` / `S6_LINE_RUNTIME` | 报工 / 过程检 / 产线 | |
+| `S7_PRODUCTION_RECEIPT` / `S7_FQC_RESULT` / `S7_QUALITY_RETURN` | 成品入库 / FQC / 质量退货 | |
+| `S7_FINISHED_ONHAND` | 成品结存 | 校验源;结存优先由期初+流水自算 |
+| `S7_FINISHED_OPENING_BALANCE` | 成品期初 | |
+
+主数据必须先于单据开通。物料更新不覆盖仓库侧共管字段(默认库位/货架/安全库存/批次策略)。
+
+### 4.7 联调脚本
+
+仓库脚本(标准库,密钥走参数或环境变量,勿写入仓库):
+
+```text
+python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-key {key} --secret {secret} --entity S1_SALES_ORDER_ENTRY
+```
+
+内置:正常推送、缺幂等键、改一字节 body、过期时间戳、nonce 重放、同 Key 重放、同 Key 不同体、partial、stale、快照 open→两批→commit。贵司对接验收至少覆盖 **partial** 与 **同 Key 重放**。
+
+---
+
+## 5. 要对哪些业务数据对接
 
 **颗粒度不够时请看两份详表(可分开发给业务 / 技术):**
 
@@ -237,16 +421,17 @@ Authorization: Bearer {约定 Token}
 - **业务名称**:给业务与第三方看的名字
 - **建议来源系统**
 - **DB 方式**:建议源表(或同义视图)+ 增量列 + 业务键
-- **API 方式**:建议路径 + 行内必须带的业务键字段
+- **API 拉取(乙)**:建议路径 + 行内必须带的业务键字段
+- **API 推送(丙)**:第 4.6 节 `entityCode`;规范字段以 `GET .../schema` 为准
 
-业务键多个字段时,Ai-DOP 用 `#` 连接,例如 `100#SO-001#1`。API 行上请同时给出这些字段,并给出稳定的 `bizKey`。
+业务键多个字段时,Ai-DOP 用 `#` 连接,例如 `100#SO-001#1`。乙的 API 行上请同时给出这些字段;丙由服务端计算 `bizKey`。
 
 图例:
 
-- **入站**:第三方提供数据,Ai-DOP 来拉
-- **回写**:见第 5 节,不在本表重复展开
+- **入站**:甲/乙由 Ai-DOP 来拉;丙由贵司按第 4 节 `entityCode` 推送
+- **回写**:见第 6 节,不在本表重复展开
 
-### 4.1 主数据(S0 / 供应共用)
+### 5.1 主数据(S0 / 供应共用)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -256,7 +441,7 @@ Authorization: Bearer {约定 Token}
 | 客户主数据 | ERP / CRM | `CustMaster` | `UpdateTime` | 账套+客户编码(`Domain,Customer`) | 可按物料同契约增开 `/api/customer` |
 | 货源清单(物料-供应商) | SRM | `srm_purchase` | `update_time` | 物料号+供应商号 | `/api/source-list` |
 
-### 4.2 产销协同(S1)
+### 5.2 产销协同(S1)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -276,7 +461,7 @@ Authorization: Bearer {约定 Token}
 | 发货单明细 | WMS | `ASNBOLShipperDetail` | `UpdateTime` | 发货单 Id+行 | `/api/shipment` |
 | 计划联动(订单-工单-物料) | ERP | `LinkagePlan` | `update_time` | 订单+物料+行 | `/api/linkage-plan` |
 
-### 4.3 制造协同(S2)
+### 5.3 制造协同(S2)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -288,7 +473,7 @@ Authorization: Bearer {约定 Token}
 | 工单领用 | MES / WMS | `WorkOrdIssued` | `UpdateTime` | 账套+工单+物料 | 可按工单明细契约增开 |
 | 完工入库 | MES / WMS | `WorkOrdInStorage` | `UpdateTime` | 账套+工单 | 可按成品入库契约复用 |
 
-### 4.4 供应协同(S3)
+### 5.4 供应协同(S3)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -305,7 +490,7 @@ Authorization: Bearer {约定 Token}
 | 工序外协配置 | ERP | `RoutingOpDetail` | `UpdatedAt` | 账套+工艺+工序+供应商 | `/api/routing-outsource` |
 | 库存现存量(齐套用) | WMS / ERP | 库存现量表 | `UpdateTime` | 账套+物料+库位 | `/api/inventory` |
 
-### 4.5 采购执行(S4)
+### 5.5 采购执行(S4)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -314,7 +499,7 @@ Authorization: Bearer {约定 Token}
 | 来料检验收货行 | WMS / QMS | `PurOrdRctDetail` | 建议补时间列 | 账套+收货单+行 | `/api/s4-iqc` |
 | 欠料(工单缺料) | MES / APS | 欠料结果表或视图 | 建议补时间列 | 工单+组件物料 | `/api/s4-shortage` |
 
-### 4.6 来料仓储 / IQC(S5)
+### 5.6 来料仓储 / IQC(S5)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -335,7 +520,7 @@ Authorization: Bearer {约定 Token}
 
 采购收货明细 API 行建议至少包含:`Domain, Receiver, Line, Supp, ItemNum, QtyOrded, QtyReceived, LotSerial, Location, OrdNbr, OrdLine, updateTime, bizKey`。
 
-### 4.7 制造执行 / 过程检验(S6)
+### 5.7 制造执行 / 过程检验(S6)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -350,7 +535,7 @@ Authorization: Bearer {约定 Token}
 | 产线运行状态 | MES | `LineStatusDet` | `UpdateTime` | 账套+产线+物料+工单+工序 | 现场按需开 |
 | 产线运行/休息 | MES | `LineRunRestDet` | `CreateTime` | 记录号 | 现场按需开 |
 
-### 4.8 成品仓储 / FQC(S7)
+### 5.8 成品仓储 / FQC(S7)
 
 | 业务名称 | 建议来源 | DB 源表/视图 | 增量列 | 业务键 | API 路径 |
 |----------|----------|--------------|--------|--------|----------|
@@ -360,9 +545,10 @@ Authorization: Bearer {约定 Token}
 | 成品检验单 | QMS | `qms_qcpp_inspbill` | 建议补时间列 | 检验单 Id | 可按 FQC 契约增开 |
 | 成品检验单明细 | QMS | `qms_qcpp_inspbillst` | 建议补时间列 | 明细 Id | 可并入检验单接口 |
 
-### 4.9 ERP 库存 / 报工账(现网 T8 只读,供指标)
+### 5.9 ERP 库存 / 报工账(T8 等价;优先推送)
 
-若现场 ERP 类似 T8(库存单据 + 报工),可用 **DB 只读** 对接下列对象,主要用于 **S5–S7 看板指标**,不替代上面的业务明细。
+若现场 ERP 类似 T8(库存单据 + 报工),**优先按第 4 节推送**等价实体:`S5_INVENTORY_TXN`(流水)、`S5_INVENTORY_OPENING_BALANCE`(期初)、`S7_FINISHED_ONHAND`(结存校验)、`S7_FINISHED_OPENING_BALANCE`(成品期初)。结存由「期初 + 流水」在中台自算,结存快照作校验。  
+无推送条件时,仍可用 **DB 只读** 对接下列对象,主要用于 **S5–S7 看板指标**,不替代上面的业务明细。
 
 | 业务名称 | 建议来源 | DB 源表 | 业务键 |
 |----------|----------|---------|--------|
@@ -375,9 +561,9 @@ Authorization: Bearer {约定 Token}
 | 员工报工头 | ERP / MES | `Cj_Bg_Head_Rep` | 记录 Id |
 | 人员主数据 | ERP | `sys_pelist` | 记录 Id |
 
-此类对象当前以 **数据库只读** 为主;若 ERP 只提供 API,需按第 3 节补同等字段的列表接口。
+不能推送时再退回甲(只读库)或乙(第 3 节 GET 列表)。切换为丙后停用该对象的抽数任务。
 
-### 4.10 异常监控(S8)与运营指标(S9)
+### 5.10 异常监控(S8)与运营指标(S9)
 
 | 模块 | 是否要第三方送数 | 说明 |
 |------|------------------|------|
@@ -386,7 +572,7 @@ Authorization: Bearer {约定 Token}
 
 ---
 
-## 5. Ai-DOP 回写给第三方的数据
+## 6. Ai-DOP 回写给第三方的数据
 
 以下动作在 Ai-DOP 业务闭环后推送。第三方需提供对应写入接口,并保证幂等。
 
@@ -406,18 +592,19 @@ Authorization: Bearer {约定 Token}
 
 ---
 
-## 6. 现场怎么选、怎么验收
+## 7. 现场怎么选、怎么验收
 
-### 6.1 选型建议
+### 7.1 选型建议
 
 | 现场情况 | 建议 |
 |----------|------|
-| 对方是现有 ERP/MES/WMS 库,表齐全 | **优先数据库直接同步**(只读账号 + 增量列) |
-| 对方是独立 SaaS,只开放接口 | **走 API**,按第 3 节提供 GET 列表 |
-| 一部分对象有库、一部分只有接口 | **按对象混用**,每个对象只选一种入站方式 |
-| 只要看板指标、暂不要业务明细 | 至少先接通第 4.9 节 ERP 库存/报工,或保证各模块主对象入站后跑指标刷新 |
+| 全部数据由对方系统推送 | **优先方式丙**(第 4 节);一对象开通后停用该对象甲/乙 |
+| 对方是现有 ERP/MES/WMS 库,表齐全、暂不能推 | **方式甲**(只读账号 + 增量列) |
+| 对方是独立 SaaS,只开放查询接口 | **方式乙**,按第 3 节提供 GET 列表 |
+| 一部分对象有库、一部分只能推 | **按对象混用**,每个对象只选一种入站方式 |
+| 只要看板指标、暂不要业务明细 | 至少先接通第 5.9 节库存流水/期初(丙优先),或保证各模块主对象入站后跑指标刷新 |
 
-### 6.2 最小开通集(先让看板 / 诊断 / ChatBI 有数)
+### 7.2 最小开通集(先让看板 / 诊断 / ChatBI 有数)
 
 若一次接不全,建议按优先级:
 
@@ -432,7 +619,7 @@ Authorization: Bearer {约定 Token}
 主对象入站并完成中台刷新后,九宫格、模块详情看板、智慧诊断 KPI 树、ChatBI 指标问答即可消费。  
 智慧诊断的「业务依据」还依赖对应明细是否同步;只接指标、不接明细时,诊断能出红黄绿,但可能点不开依据。
 
-### 6.3 联调检查清单
+### 7.3 联调检查清单
 
 **数据库方式**
 
@@ -441,13 +628,22 @@ Authorization: Bearer {约定 Token}
 - [ ] 抽样抽数成功,贴源表有新行  
 - [ ] 改一条业务数据并刷新时间列后,增量能抽到  
 
-**API 方式**
+**API 拉取(乙)**
 
 - [ ] `GET` + Token 返回 `2xx`,列表在 `data.list`  
 - [ ] 每行有 `bizKey` 及业务键字段  
 - [ ] `?cursor=` 只返回更新的数据  
 - [ ] 贴源表有新行,与抽样键一致  
 
+**标准 API 推送(丙)**
+
+- [ ] 签名头齐全;错签 / 过期 / 缺 `Idempotency-Key` 分别得到 401 / 401 / 400  
+- [ ] 正常推送 HTTP 202;有拒绝行时 `code=2` 且客户端处理 `rejectedRows`  
+- [ ] 同 Key 同 body 第二次为 replay,贴源仍一行  
+- [ ] 每行带带时区的 `sourceUpdatedAt`;旧时间重推进 stale  
+- [ ] 主数据走快照:open → 连续 seq → commit;未 commit 无删除  
+- [ ] `GET .../digest?date=` 与当天推送键对得上  
+
 **回写**
 
 - [ ] 对应 POST 接口可接收并返回 2xx  
@@ -461,23 +657,33 @@ Authorization: Bearer {约定 Token}
 
 ---
 
-## 7. 安全与边界
+## 8. 安全与边界
 
-1. **凭据不入库、不进文档、不进日志明文。**  
-2. 入站账号 **只读**;回写走独立写接口或独立账号,权限最小化。  
+1. **凭据不入库、不进文档、不进日志明文。** 方式丙的 Secret 只带外交付,轮换期可双 Key 并行。  
+2. 方式甲入站账号 **只读**;回写走独立写接口或独立账号,权限最小化。方式丙只开放已授权 `entityCode`,并建议加 IP 白名单。  
 3. 看板、诊断、ChatBI **禁止**直连第三方库或接口。  
-4. 第三方表结构变更时,请提前通知,优先用视图保持对外列稳定。  
-5. 多租户现场必须能区分账套/工厂/租户,避免串数。  
-6. ChatBI 当前只基于同步后的 **指标** 问答,不会查询第三方原始明细,也不向第三方库发 SQL。
+4. 第三方表结构变更时,请提前通知;丙用规范字段 + 映射表,避免把一家 ERP 列名写成公共契约。  
+5. 多租户现场必须能区分账套/工厂/租户,避免串数。凭证绑定租户,禁止在报文里指定他人租户。  
+6. ChatBI 当前只基于同步后的 **指标** 问答,不会查询第三方原始明细,也不向第三方库发 SQL。  
+7. 入站签名凭证不能调用登录态业务 API(必然 401)。
 
 ---
 
-## 8. 双方分工
+## 9. 双方分工
 
 | 角色 | 负责 |
 |------|------|
-| 第三方系统 | 按第 4、5 节提供库表/视图或 API;保证增量列与业务键;接收回写并幂等 |
-| 现场网络 / 安全 | 开通只读库或 API 白名单;带外交付账号与 Token |
-| Ai-DOP 实施 | 登记数据源与实体、配置增量窗口、跑抽数与刷新、核对看板/诊断 |
+| 第三方系统 | 按第 5、6 节提供库表/视图或查询 API;或按第 4 节推送;保证业务键与 `sourceUpdatedAt`;接收回写并幂等 |
+| 现场网络 / 安全 | 开通只读库、查询 API 或推送出口白名单;带外交付账号、Token 或 AccessKey/Secret |
+| Ai-DOP 实施 | 登记数据源/授权、开通 `entityCode`、跑抽数或联调推送、刷新后核对看板/诊断 |
+
+联调排期时,请先确认每个对象走 **甲 / 乙 / 丙**,再按第 5 节签收字段;走丙时以 `GET .../schema` 与第 4.6 节编码为准。
 
-联调排期时,请先确认每个对象走 **数据库** 还是 **API**,再按第 4 节逐张表或逐个接口签收字段。
+---
+
+## 变更记录
+
+| 日期 | 说明 |
+|------|------|
+| 2026-09-04 | 初版:甲(库同步)+ 乙(我方拉取)+ 回写 |
+| 2026-09-11 | 按方式丙全部完成后的契约增补第 4 章(`API_INBOUND`);甲/乙改为迁移或对方不能推时的选项;T8 优先推流水/期初;选型与验收同步 |

+ 8 - 5
doc/plan/系统集成/README.md

@@ -6,9 +6,9 @@
 
 | 文档 | Word | 读者 | 说明 |
 |------|------|------|------|
-| [Ai-DOP第三方系统集成指南.md](./Ai-DOP第三方系统集成指南.md) | [同名 .docx](./Ai-DOP第三方系统集成指南.docx) | 第三方厂商与实施 | 入站(DB 同步 / API 拉取)、回写、联调验收 |
-| [Ai-DOP第三方对接业务数据详表-业务页.md](./Ai-DOP第三方对接业务数据详表-业务页.md) | [同名 .docx](./Ai-DOP第三方对接业务数据详表-业务页.docx) | 业务对接窗口 | 九宫格 / 详情看板 / 诊断 / ChatBI 要哪些业务数据 |
-| [Ai-DOP第三方对接业务数据详表-技术页.md](./Ai-DOP第三方对接业务数据详表-技术页.md) | [同名 .docx](./Ai-DOP第三方对接业务数据详表-技术页.docx) | 开发 / 中台配置 | 源字段 → 贴源 / 标准层 / 宽表;四套功能读哪些表 |
+| [Ai-DOP第三方系统集成指南.md](./Ai-DOP第三方系统集成指南.md) | [同名 .docx](./Ai-DOP第三方系统集成指南.docx) | 第三方厂商与实施 | 入站(甲库同步 / 乙我方拉取 / **丙标准 API 推送**)、回写、联调验收 |
+| [Ai-DOP第三方对接业务数据详表-业务页.md](./Ai-DOP第三方对接业务数据详表-业务页.md) | [同名 .docx](./Ai-DOP第三方对接业务数据详表-业务页.docx) | 业务对接窗口 | 九宫格 / 详情看板 / 诊断 / ChatBI 要哪些业务数据;§0.1 对照方式丙 `entityCode` |
+| [Ai-DOP第三方对接业务数据详表-技术页.md](./Ai-DOP第三方对接业务数据详表-技术页.md) | [同名 .docx](./Ai-DOP第三方对接业务数据详表-技术页.docx) | 开发 / 中台配置 | 源字段 → 贴源 / 标准层 / 宽表;丙公共列与 T8 推送等价;四套功能读哪些表 |
 
 重新生成 Word(需本机已装 `python-docx`):
 
@@ -21,6 +21,9 @@ python doc/plan/系统集成/_gen_integration_guide_docx.py
 | 文档 | 说明 |
 |------|------|
 | [AIDOP双模式架构与第三方对接现状基线.md](../AIDOP双模式架构与第三方对接现状基线.md) | 双模式与现网对接深度基线 |
-| [AIDOP双模式全模块对接交付级任务书.md](../AIDOP双模式全模块对接交付级任务书.md) | 全模块对接交付任务(含规划中的入站推送 P-2) |
+| [AIDOP双模式全模块对接交付级任务书.md](../AIDOP双模式全模块对接交付级任务书.md) | 全模块对接交付任务(P-2 入站推送;实施名改为 `API_INBOUND`) |
+| [第三方标准API推数入站方案.md](../第三方标准API推数入站方案.md) | **方式丙终态**:`POST /api/mdp/inbound/{entityCode}`;身份/契约/摄取/规范/发布五层 |
+| [第三方标准API推数入站执行任务书.md](../第三方标准API推数入站执行任务书.md) | P-2 执行任务书(A–F 编码完成);剩余见项目待办 **P-046** |
+| [第三方标准API推数入站-阶段0字段对表草稿.md](../第三方标准API推数入站-阶段0字段对表草稿.md) | 阶段 0:`S1_SALES_ORDER_ENTRY` / `MDM_ITEM` / `S5_INVENTORY_TXN` 字段对表 + 库存口径确认(发布前会签) |
 | [旧DOP-MES-WMS/对接任务书/README.md](../旧DOP-MES-WMS/对接任务书/README.md) | 对接 165 SQL Server(MES/WMS)执行任务书 |
-| [UAT测试/内部UAT测试待办项.md](../UAT测试/内部UAT测试待办项.md) | `UAT-INT-003` S0 回灌、`UAT-INT-004` 对方推数入站(本轮不做) |
+| [UAT测试/内部UAT测试待办项.md](../UAT测试/内部UAT测试待办项.md) | `UAT-INT-003` S0 镜像(入站路径已接,页只读化待做);`UAT-INT-004` 方式丙代码已落,指南第 4 章已写 |

+ 74 - 0
doc/plan/针对第三方标准API推数入站方案的评审.md

@@ -0,0 +1,74 @@
+以下按 P0(不修不能开码)/ P1(首期必须定)/ P2(可后置但要留位)分级。我无法验证 Admin.NET SysOpenAccess 内部实现,凡涉及框架行为的判断都标了「需核」。
+
+P0:会导致数据错、被篡改或静默丢数的漏洞
+
+1. 签名不覆盖 body,contentHash 放在 body 里等于没签。
+§4.2 签名串只含 方法&路径&key&时间戳&nonce。contentHash 本身在未签名的 body 中,任何能改 body 的中间环节(网关 TLS 终结后到应用的明文一跳、有问题的反代、被劫持的客户端出口)可以同时改 list 和 contentHash,服务端重算必然一致。它只防截断,不防篡改;§8 把它写进「完整性」一行是误导。
+另外 Idempotency-Key 头也不在签名内,可被剥离/替换。
+替代:body 摘要进签名串——X-Content-SHA256 = sha256(原始请求字节),签名串改为 POST&path&key&ts&nonce&contentSha256&idempotencyKey。这需要自定义签名事件而非直接用框架默认串(需核:GetSignatureAuthenticationEventImpl 是否允许扩展签名串;若不能,§11「框架 Core 不改」不成立,要提前承认)。同时废掉「规范化 JSON 后再哈希」:跨语言 JSON 规范化(键序、Unicode 转义、浮点表示、尾零)没有统一实现,异构对接方必然在联调阶段无限撞 400。对原始字节哈希,零歧义。
+
+2. 机器凭证可能是「通用登录态」。
+验签通过后框架注入完整 Claims(租户 + 机器用户),如果其它 [Authorize] 控制器只查登录态不查角色,这把 AccessKey 就能调所有业务 API。「无菜单权限」只挡前端菜单,不挡 API(需核:默认授权策略是否接受 Signature scheme)。
+替代:Signature 作为独立 AuthenticationScheme,只在 MdpInboundController 上 [Authorize(AuthenticationSchemes = "Signature", Policy = "Inbound")];全局默认策略显式排除该 scheme。安全回归用例加一条:用入站签名调任意登录态 API 必须 401。
+
+3. 请求级幂等与贴源写入没有原子性,重试会产生假回执。
+流程是「校验 → 写 stg → 记 mdp_inbound_request」。500 行写到第 300 行数据库故障 → 500 → 对方用同一 Key 重试。三种结果都错:(a) 请求记录已写、stg 半写 → 重放返回上次「accepted=500」,200 行永久丢;(b) 请求记录未写 → 重新写全,可行但依赖行级 upsert 兜底;(c) 两次重试并发 → 无「进行中」锁,双写。
+替代:mdp_inbound_request 增状态机 RECEIVED → COMMITTED / FAILED,先插 RECEIVED(唯一索引即锁,冲突且状态 RECEIVED 返回 409 in-progress),stg 写入与状态置 COMMITTED 同一事务;FAILED 允许同 Key 重试。并明确 Key 必填而非可选。
+
+4. 全量快照的「批次结束」无定义,半截快照会把主数据批量停用。
+主数据 2000 行/请求,5 万物料要 25 个请求。§4.3 说「批次结束后未出现的行软删」,但没有 begin/commit 协议——服务端不知道第 13 个请求后对方网络断了还是推完了。结果:一半物料被标 INACTIVE → §7.3 镜像同步到 ItemMaster → 评审选料、名称解析全线断。这是本方案里最可能在现场出的事故。
+替代:POST /inbound/{entity}/snapshots 开启(返回 snapshotId)→ 各请求带 snapshotId + seq → POST /snapshots/{id}/commit 显式提交,提交时才做差集;未提交快照 N 小时过期作废;差集停用比例超阈值(如 >20%)时拒绝自动执行,转人工确认。
+
+5. stale 守卫的裁决规则有三个未定义分支。
+
+sourceUpdatedAt 相等但内容不同(源系统秒级精度、或部分字段变更不刷时间戳)→ 按「新不覆盖旧」会拒,且 202 里只给计数不给行——对方以为成功。
+时间无时区(示例 2026-09-01 10:00:00),多家源系统时区/时钟不同。
+PULL 通道的行没有同语义的 source_updated_at(用 cursor),双源并存期比较结果未定义。
+替代:相等时按内容哈希决定是否写;stale 行进 rejectedRows,reason=stale(可关闭);时间字段强制 ISO 8601 带偏移;允许可选 sourceVersion 单调序号优先于时间戳;PULL 落库时补写同语义列,否则双源期关闭守卫。
+
+6. 202 + rejectedRows 是静默丢数的温床。
+绝大多数对接方的 HTTP 客户端只看 2xx。行级拒绝 2 行、code=0,对方永远不会看 body。
+替代:有拒绝行时 code≠0(如 code=2 partial),并在对账 digest 里把 rejected 数量列为独立字段;对方侧对接验收必须包含「处理 partial」用例。
+
+P1:契约层面的结构性问题
+
+7. 对外字段名 = 内部转换 SQL 的源字段名,是把首个客户 ERP 的列名固化成公共契约。
+rdate、scm_shdzb、PurOrdRctDetail 这类名字带着特定 ERP 血统。§4 说「一次定死、版本化演进」,§1.1 说「多家异构」,二者矛盾:第二家 ERP 的语义不同(如「交货日」在 SAP 和金蝶含义不同),要么对方改数据凑名字,要么你改转换 SQL(违反「零改动」)。且大小写不敏感 + 同一行出现 Rdate/rdate 两个键时行为未定义。
+替代:两层——对外用 AiDOP 规范字段(从 mdp_entity 元数据生成 JSON Schema,GET /inbound/{entity}/schema 可取);服务端按「实体 × 对接方」的 mdp_field_map 映射到 raw_data 键。转换 SQL 确实零改动,但对外契约与内部实现解耦。首期可让规范字段 = 现有键(成本为零),关键是机制先立,否则第二家客户就要翻契约。
+
+8. bizKey 由对方给还是服务端算,未决。
+§4.3 说 bizKey 必填且「与 biz_key_expr 同源」,但没说校验。对方拼错分隔符或字段顺序,就会与 PULL 通道的行产生重复键,行级幂等失效。
+替代:服务端按 biz_key_expr 从字段计算,bizKey 改为可选校验字段,不一致 → 行级拒绝。
+
+9. 跨实体依赖与到达顺序。
+行先于头(S1_SALES_ORDER_ENTRY 早于 S1_SALES_ORDER)、单据先于主数据、收货先于采购单——转换时外键解析为空。后到的主数据会重新触发 S1 重建吗?§7.2 只按「实体所属模块」入队,MDM 不属任何模块。
+替代:声明依赖图(MDM → S1–S7;头 → 行),MDM 推送触发所有依赖模块;转换对孤儿行的策略写死(保留待补 vs 丢弃)并可观测「孤儿计数」。
+
+10. 事件加急的持久性与成本。
+EnqueueAsync 若是进程内队列,多实例部署下入队的实例未必是执行的实例,60 分钟兜底会把「分钟级」SLA 变成「最坏 60 分钟」。且每次触发若是整模块重建,60 rpm 推送 × 30 实体会把重建打成风暴,去抖只能缓解不能解决。
+替代:mdp_rebuild_request 持久化触发表(租户/工厂/模块/biz_date 范围),由 leader 消费并合并;重建支持按 biz_date 窗口增量,而非整模块。需核:现有 Rebuild 是否支持任意历史日期窗口——否则 §7.5「补推上月收货触发 KPI 重算」是空话。
+
+11. 初始化装载没有通道。
+60 rpm × 500 行 = 3 万行/分。三年销售订单行几百万,加库存流水,首日全量要按天计,而且全部走事件加急。
+替代:独立的批量装载模式(NDJSON 分块上传或临时放宽限流 + 关闭事件加急、装载完一次性重建),与日常推送分开。
+
+12. T8 等价实体的可行性未验证。
+让 ERP 推「库存结存快照」在很多系统里不可得(只有流水)。若只推流水,需要期初余额实体,否则结存算不出。
+替代:实体矩阵增 INVENTORY_OPENING_BALANCE;结存优先由流水 + 期初自算,ONHAND 快照作为校验源。这需要先拿目标客户 ERP 确认,是方案能否成立的前置条件,不应放在阶段 E。
+
+13. S0 镜像绕过领域逻辑。
+MdmMirrorUpsertService 直写 ItemMaster,S0 侧的校验、审计、缓存失效、WP9 共管字段保护是否都走到?「本库手建行不受影响」——同一物料编码本地手建过又推送过,键冲突策略未写。
+替代:镜像 upsert 走 S0 领域服务而非直写表;键冲突先落「冲突表」人工裁决,不覆盖。
+
+P2:细节但会在联调期反复消耗时间
+nonce 去重存储与 Idempotency-Key 混为一谈;nonce 需独立短窗口存储(缓存),Key 需长窗口(≥ 对方重试策略上限,建议 7 天),P-011 清理窗口要 ≥ 此值。
+密钥轮换「双密钥灰度」:框架一 key 一 secret(需核),实际要为同一对接方签两个 AccessKey,mdp_inbound_grant 要允许同一对接方多 key。
+工厂维度:grant 绑工厂可选,行内是否必须带工厂、多工厂推送如何校验,未写。
+对账 bizKey 清单哈希未定义排序规则;「推送沉默告警」阈值要按实体 × 日历(周末/停产)配置,否则周一早上一片红。
+租户不一致返回 409 语义偏,建议 403;HTTP 语义一旦发布不好改。
+原始信封归档从「至多」改为首期必做(保留 N 天):转换 SQL 出 bug 后能从信封重放,这是推送模式相对拉取模式唯一的补救手段。
+一条更稳健的整体形态
+
+当前设计把「校验 + 写 stg + 记审计 + 触发重建」都压在 HTTP 请求路径。更稳的分层:同步只做鉴权、结构校验、原始信封持久化,返回 202 + requestId;异步 worker 解析→stg→触发重建,结果通过 receipts/{id} 查询。代价是行级拒绝不再同步返回;收益是 DB 抖动不影响接收、重试语义简单、可从信封重放、多实例天然安全。若坚持同步回执,至少把 P0-3 的状态机和信封归档补上。
+
+最后一点风险不在技术:§6 约 30 个实体 × 每个对接方的字段语义会签,没有责任人和时间盒,而这才是阶段 H 拖延的真实原因。建议阶段 A 之前先用目标客户 ERP 做 3 个实体的真实字段对表,验证 P1-7 和 P1-12 再定契约形态。

+ 21 - 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.535</AssemblyVersion>
-    <FileVersion>1.0.535</FileVersion>
-    <Version>1.0.535</Version>
+    <AssemblyVersion>1.0.538</AssemblyVersion>
+    <FileVersion>1.0.538</FileVersion>
+    <Version>1.0.538</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -829,6 +829,24 @@
     <None Update="UpdateScripts\1.0.535.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.536.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.536.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.537.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.537.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.538.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.538.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 194 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.536.sql

@@ -0,0 +1,194 @@
+-- ============================================================================
+-- 1.0.536 · API_INBOUND WP-A:入站 6 表 + mdp_entity 扩列 + 联调种子(号段避让远端已占用的 1.0.527)
+--
+-- 幂等:CREATE TABLE IF NOT EXISTS;扩列 information_schema + PREPARE 自包含;
+-- 种子 INSERT ... WHERE NOT EXISTS。AutoVersionUpdate 按 ; 拆句、无事务。
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS mdp_inbound_grant (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL COMMENT '凭证绑定租户(冗余自 SysOpenAccess.BindTenantId)',
+    access_key VARCHAR(64) NOT NULL COMMENT 'SysOpenAccess.AccessKey',
+    entity_code VARCHAR(100) NOT NULL COMMENT 'mdp_entity.entity_code 白名单',
+    factory_id BIGINT NULL COMMENT '可选绑定工厂',
+    source_code VARCHAR(50) NOT NULL COMMENT 'mdp_source.source_code(source_type=API_INBOUND)',
+    rate_limit_per_min INT NOT NULL DEFAULT 60,
+    batch_row_limit INT NOT NULL DEFAULT 500 COMMENT '主数据快照类可放宽到 2000',
+    ip_allowlist VARCHAR(1000) NULL COMMENT '逗号分隔;空=不限制',
+    status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
+    remark VARCHAR(500) NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_grant (access_key, entity_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方推数入站授权';
+
+CREATE TABLE IF NOT EXISTS mdp_inbound_request (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    idempotency_key VARCHAR(128) NOT NULL,
+    request_hash CHAR(64) NOT NULL COMMENT 'sha256(原始请求体字节) hex',
+    sync_batch_id VARCHAR(64) NOT NULL COMMENT 'INB_yyyyMMdd_{id 零填充}',
+    snapshot_id VARCHAR(64) NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT 'RECEIVED/COMMITTED/FAILED',
+    http_status INT NOT NULL DEFAULT 0,
+    result_code INT NOT NULL DEFAULT 0 COMMENT '回执 code:0 ok / 2 partial',
+    accepted INT NOT NULL DEFAULT 0,
+    rejected INT NOT NULL DEFAULT 0,
+    stale_rejected INT NOT NULL DEFAULT 0,
+    equal_ts_override INT NOT NULL DEFAULT 0,
+    receipt_json MEDIUMTEXT NULL,
+    error_msg VARCHAR(1000) NULL,
+    client_ip VARCHAR(64) NULL,
+    elapsed_ms INT NOT NULL DEFAULT 0,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_idem (tenant_id, access_key, entity_code, idempotency_key),
+    KEY idx_inbound_req_batch (sync_batch_id),
+    KEY idx_inbound_req_entity_time (entity_code, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方推数入站请求(幂等状态机+审计)';
+
+CREATE TABLE IF NOT EXISTS mdp_inbound_envelope (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    request_id BIGINT NOT NULL COMMENT 'mdp_inbound_request.id',
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    path VARCHAR(300) NOT NULL,
+    headers_json TEXT NULL COMMENT '脱敏后头集合',
+    body MEDIUMTEXT NOT NULL,
+    body_sha256 CHAR(64) NOT NULL,
+    create_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_envelope_req (request_id),
+    KEY idx_inbound_envelope_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站原始信封归档(重放用)';
+
+CREATE TABLE IF NOT EXISTS mdp_inbound_snapshot (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    snapshot_id VARCHAR(64) NOT NULL,
+    tenant_id BIGINT NOT NULL,
+    access_key VARCHAR(64) NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/COMMITTED/EXPIRED/DIFF_BLOCKED',
+    last_seq INT NOT NULL DEFAULT 0,
+    row_count INT NOT NULL DEFAULT 0,
+    diff_missing INT NULL,
+    diff_ratio DECIMAL(6,4) NULL,
+    opened_at DATETIME NOT NULL,
+    expire_at DATETIME NOT NULL,
+    committed_at DATETIME NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_inbound_snapshot (snapshot_id),
+    KEY idx_inbound_snap_entity (tenant_id, entity_code, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站全量快照批次';
+
+CREATE TABLE IF NOT EXISTS mdp_inbound_conflict (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    biz_key VARCHAR(300) NOT NULL,
+    source_system VARCHAR(50) NOT NULL,
+    incoming_raw MEDIUMTEXT NOT NULL,
+    mirror_table VARCHAR(64) NOT NULL,
+    conflict_type VARCHAR(40) NOT NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RESOLVED',
+    resolution VARCHAR(500) NULL,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    KEY idx_inbound_conflict (tenant_id, entity_code, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主数据镜像键冲突待裁决';
+
+CREATE TABLE IF NOT EXISTS mdp_field_map (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    entity_code VARCHAR(100) NOT NULL,
+    access_key VARCHAR(64) NOT NULL DEFAULT '*' COMMENT '精确对接方优先;*=该实体默认映射',
+    contract_field VARCHAR(200) NOT NULL,
+    raw_key VARCHAR(200) NOT NULL,
+    is_required TINYINT NOT NULL DEFAULT 0,
+    status TINYINT NOT NULL DEFAULT 1,
+    create_time DATETIME NOT NULL,
+    update_time DATETIME NOT NULL,
+    UNIQUE KEY uk_field_map (tenant_id, entity_code, access_key, contract_field)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入站对外契约字段两层映射';
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='mdp_entity' AND COLUMN_NAME='inbound_enabled');
+SET @sql := IF(@exist=0, "ALTER TABLE mdp_entity ADD COLUMN inbound_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '入站开通开关'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='mdp_entity' AND COLUMN_NAME='inbound_max_rows');
+SET @sql := IF(@exist=0, "ALTER TABLE mdp_entity ADD COLUMN inbound_max_rows INT NOT NULL DEFAULT 500 COMMENT '单请求行数上限(快照类 2000)'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='mdp_entity' AND COLUMN_NAME='stale_guard_enabled');
+SET @sql := IF(@exist=0, "ALTER TABLE mdp_entity ADD COLUMN stale_guard_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '乱序 stale 守卫'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='mdp_entity' AND COLUMN_NAME='silence_alert_hours');
+SET @sql := IF(@exist=0, "ALTER TABLE mdp_entity ADD COLUMN silence_alert_hours INT NULL COMMENT '沉默告警阈值(小时);NULL=不告警'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='mdp_entity' AND COLUMN_NAME='silence_calendar');
+SET @sql := IF(@exist=0, "ALTER TABLE mdp_entity ADD COLUMN silence_calendar VARCHAR(20) NULL COMMENT '逗号分隔星期 1-7;空=每天'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+INSERT INTO mdp_source
+  (tenant_id, source_code, source_name, source_type, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TEST_INBOUND_ERP', '联调入站源 API_INBOUND', 'API_INBOUND', 1,
+       'WP-A 联调种子;正式对接方另签发', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_source WHERE source_code='TEST_INBOUND_ERP');
+
+INSERT INTO SysOpenAccess (Id, AccessKey, AccessSecret, BindTenantId, BindUserId, CreateTime, UpdateTime)
+SELECT 910000000000001, 'TESTKPARTNER', 'TESTKPARTNER_SECRET_DO_NOT_USE_IN_PROD',
+       797403760988229, 797403762421829, NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM SysOpenAccess WHERE AccessKey='TESTKPARTNER');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT e.tenant_id, s.id, 'S1_SALES_ORDER_ENTRY', '销售订单行(入站草案)', e.entity_type, e.source_table_name, e.target_table_name,
+       e.sync_mode, e.batch_size, e.biz_key_expr, 1, 1, 500, 1,
+       'v1-draft 对外码,贴源/转换复用 S1_SEORDER_ENTRY', NOW(), NOW()
+FROM mdp_entity e
+JOIN mdp_source s ON s.source_code='TEST_INBOUND_ERP'
+WHERE e.entity_code='S1_SEORDER_ENTRY'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='S1_SALES_ORDER_ENTRY');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT e.tenant_id, s.id, 'MDM_ITEM', '物料主数据(入站草案)', e.entity_type, e.source_table_name, e.target_table_name,
+       e.sync_mode, e.batch_size, e.biz_key_expr, 1, 1, 500, 1,
+       'v1-draft 对外码,贴源/转换复用 S3_ITEM_ERP', NOW(), NOW()
+FROM mdp_entity e
+JOIN mdp_source s ON s.source_code='TEST_INBOUND_ERP'
+WHERE e.entity_code='S3_ITEM_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='MDM_ITEM');
+
+UPDATE mdp_entity
+   SET inbound_enabled=1, inbound_max_rows=500, stale_guard_enabled=1, update_time=NOW()
+ WHERE entity_code IN ('S1_SALES_ORDER_ENTRY', 'S1_SEORDER_ENTRY', 'MDM_ITEM');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'S1_SALES_ORDER_ENTRY', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-A 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='S1_SALES_ORDER_ENTRY');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'MDM_ITEM', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-A 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='MDM_ITEM');

+ 32 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.536.verify.sql

@@ -0,0 +1,32 @@
+-- 1.0.536.verify.sql — 每条 SELECT 必须返回真值
+
+SELECT COUNT(*) = 6
+  FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+   AND table_name IN (
+     'mdp_inbound_grant', 'mdp_inbound_request', 'mdp_inbound_envelope',
+     'mdp_inbound_snapshot', 'mdp_inbound_conflict', 'mdp_field_map');
+
+SELECT COUNT(*) = 2
+  FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+   AND table_name = 'mdp_inbound_grant'
+   AND index_name = 'uk_inbound_grant'
+   AND (
+        (seq_in_index = 1 AND column_name = 'access_key')
+     OR (seq_in_index = 2 AND column_name = 'entity_code'));
+
+SELECT COUNT(*) = 4
+  FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+   AND table_name = 'mdp_inbound_request'
+   AND index_name = 'uk_inbound_idem'
+   AND column_name IN ('tenant_id', 'access_key', 'entity_code', 'idempotency_key');
+
+SELECT COUNT(*) = 5
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'mdp_entity'
+   AND column_name IN (
+     'inbound_enabled', 'inbound_max_rows', 'stale_guard_enabled',
+     'silence_alert_hours', 'silence_calendar');

+ 25 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.537.sql

@@ -0,0 +1,25 @@
+-- ============================================================================
+-- 1.0.537 · API_INBOUND WP-B:入站实体标注 op=delete 未开放;校正 biz_key_expr(号段避让远端已占用的 1.0.528)
+--
+-- 现网 S1/S3 转换 SQL 不读 raw_data.is_deleted(任务书 §10 陷阱 11)。
+-- 未写 inbound_delete=supported 的实体,接收管线拒绝 op=delete。
+-- AutoVersionUpdate 按 ; 拆句、无事务。
+-- ============================================================================
+
+UPDATE mdp_entity
+   SET remark = TRIM(CONCAT(IFNULL(remark, ''), ' inbound_delete=unsupported')),
+       update_time = NOW()
+ WHERE entity_code IN ('S1_SALES_ORDER_ENTRY', 'MDM_ITEM')
+   AND IFNULL(remark, '') NOT LIKE '%inbound_delete=%';
+
+UPDATE mdp_entity
+   SET biz_key_expr = 'bill_no,entry_seq',
+       update_time = NOW()
+ WHERE entity_code = 'S1_SALES_ORDER_ENTRY'
+   AND (biz_key_expr IS NULL OR biz_key_expr LIKE 'CONCAT%' OR TRIM(biz_key_expr) = '');
+
+UPDATE mdp_entity
+   SET biz_key_expr = 'Domain,ItemNum',
+       update_time = NOW()
+ WHERE entity_code = 'MDM_ITEM'
+   AND (biz_key_expr IS NULL OR biz_key_expr LIKE 'CONCAT%' OR TRIM(biz_key_expr) = '');

+ 16 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.537.verify.sql

@@ -0,0 +1,16 @@
+-- 1.0.537.verify.sql — 每条 SELECT 必须返回真值
+
+SELECT COUNT(*) = 2
+  FROM mdp_entity
+ WHERE entity_code IN ('S1_SALES_ORDER_ENTRY', 'MDM_ITEM')
+   AND IFNULL(remark, '') LIKE '%inbound_delete=unsupported%';
+
+SELECT COUNT(*) = 1
+  FROM mdp_entity
+ WHERE entity_code = 'S1_SALES_ORDER_ENTRY'
+   AND biz_key_expr = 'bill_no,entry_seq';
+
+SELECT COUNT(*) = 1
+  FROM mdp_entity
+ WHERE entity_code = 'MDM_ITEM'
+   AND biz_key_expr = 'Domain,ItemNum';

+ 146 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.538.sql

@@ -0,0 +1,146 @@
+-- ============================================================================
+-- 1.0.538 · API_INBOUND WP-D/E:S0 五表来源列 + T8/员工入站实体(号段避让远端已占用的 1.0.530)
+--
+-- 幂等:扩列 information_schema + PREPARE 自包含;实体 INSERT ... WHERE NOT EXISTS。
+-- AutoVersionUpdate 按 ; 拆句、无事务。
+-- ============================================================================
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ItemMaster' AND COLUMN_NAME='source_system');
+SET @sql := IF(@exist=0, "ALTER TABLE ItemMaster ADD COLUMN source_system VARCHAR(50) NULL COMMENT '入站来源'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ItemMaster' AND COLUMN_NAME='source_updated_at');
+SET @sql := IF(@exist=0, "ALTER TABLE ItemMaster ADD COLUMN source_updated_at DATETIME NULL COMMENT '入站源更新时间'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='CustMaster' AND COLUMN_NAME='source_system');
+SET @sql := IF(@exist=0, "ALTER TABLE CustMaster ADD COLUMN source_system VARCHAR(50) NULL COMMENT '入站来源'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='CustMaster' AND COLUMN_NAME='source_updated_at');
+SET @sql := IF(@exist=0, "ALTER TABLE CustMaster ADD COLUMN source_updated_at DATETIME NULL COMMENT '入站源更新时间'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='SuppMaster' AND COLUMN_NAME='source_system');
+SET @sql := IF(@exist=0, "ALTER TABLE SuppMaster ADD COLUMN source_system VARCHAR(50) NULL COMMENT '入站来源'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='SuppMaster' AND COLUMN_NAME='source_updated_at');
+SET @sql := IF(@exist=0, "ALTER TABLE SuppMaster ADD COLUMN source_updated_at DATETIME NULL COMMENT '入站源更新时间'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='LocationMaster' AND COLUMN_NAME='source_system');
+SET @sql := IF(@exist=0, "ALTER TABLE LocationMaster ADD COLUMN source_system VARCHAR(50) NULL COMMENT '入站来源'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='LocationMaster' AND COLUMN_NAME='source_updated_at');
+SET @sql := IF(@exist=0, "ALTER TABLE LocationMaster ADD COLUMN source_updated_at DATETIME NULL COMMENT '入站源更新时间'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='EmployeeMaster' AND COLUMN_NAME='source_system');
+SET @sql := IF(@exist=0, "ALTER TABLE EmployeeMaster ADD COLUMN source_system VARCHAR(50) NULL COMMENT '入站来源'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='EmployeeMaster' AND COLUMN_NAME='source_updated_at');
+SET @sql := IF(@exist=0, "ALTER TABLE EmployeeMaster ADD COLUMN source_updated_at DATETIME NULL COMMENT '入站源更新时间'", 'SELECT 1');
+PREPARE s FROM @sql;
+EXECUTE s;
+DEALLOCATE PREPARE s;
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT 797403760988229, s.id, 'S5_INVENTORY_TXN', '库存流水(入站草案)', 'TABLE', 'S5_INVENTORY_TXN', 'mdp_stg_t8_kc_tz_list',
+       'FULL', 500, 'Id', 1, 1, 500, 1,
+       'v1-draft Q7方案A:拍平行落 tz_list,头字段冗余在 raw_data inbound_delete=unsupported', NOW(), NOW()
+FROM mdp_source s
+WHERE s.source_code='TEST_INBOUND_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='S5_INVENTORY_TXN');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT 797403760988229, s.id, 'S5_INVENTORY_OPENING_BALANCE', '库存期初(入站草案)', 'TABLE', 'S5_INVENTORY_OPENING_BALANCE', 'mdp_stg_t8_kc_tz_list',
+       'FULL', 500, 'Id', 1, 1, 500, 1,
+       'v1-draft 与流水同分表、source_table 隔离 inbound_delete=unsupported', NOW(), NOW()
+FROM mdp_source s
+WHERE s.source_code='TEST_INBOUND_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='S5_INVENTORY_OPENING_BALANCE');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT 797403760988229, s.id, 'S7_FINISHED_ONHAND', '成品结存(入站草案)', 'TABLE', 'S7_FINISHED_ONHAND', 'mdp_stg_t8_kc_zj_list',
+       'FULL', 500, 'Id', 1, 1, 500, 1,
+       'v1-draft 落 kc_zj_list inbound_delete=unsupported', NOW(), NOW()
+FROM mdp_source s
+WHERE s.source_code='TEST_INBOUND_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='S7_FINISHED_ONHAND');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT 797403760988229, s.id, 'S7_FINISHED_OPENING_BALANCE', '成品期初(入站草案)', 'TABLE', 'S7_FINISHED_OPENING_BALANCE', 'mdp_stg_t8_kc_zj_list',
+       'FULL', 500, 'Id', 1, 1, 500, 1,
+       'v1-draft inbound_delete=unsupported', NOW(), NOW()
+FROM mdp_source s
+WHERE s.source_code='TEST_INBOUND_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='S7_FINISHED_OPENING_BALANCE');
+
+INSERT INTO mdp_entity
+  (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name,
+   sync_mode, batch_size, biz_key_expr, status, inbound_enabled, inbound_max_rows, stale_guard_enabled,
+   remark, create_time, update_time)
+SELECT 797403760988229, s.id, 'MDM_EMPLOYEE_HEADCOUNT', '雇员主数据(入站草案)', 'TABLE', 'EmployeeMaster', 'mdp_stg_employee',
+       'FULL', 500, 'Domain,Employee', 1, 1, 500, 1,
+       'v1-draft 落 mdp_stg_employee inbound_delete=unsupported', NOW(), NOW()
+FROM mdp_source s
+WHERE s.source_code='TEST_INBOUND_ERP'
+  AND NOT EXISTS (SELECT 1 FROM mdp_entity x WHERE x.entity_code='MDM_EMPLOYEE_HEADCOUNT');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'S5_INVENTORY_TXN', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-E 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='S5_INVENTORY_TXN');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'S5_INVENTORY_OPENING_BALANCE', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-E 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='S5_INVENTORY_OPENING_BALANCE');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'S7_FINISHED_ONHAND', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-E 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='S7_FINISHED_ONHAND');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'S7_FINISHED_OPENING_BALANCE', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-E 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='S7_FINISHED_OPENING_BALANCE');
+
+INSERT INTO mdp_inbound_grant
+  (tenant_id, access_key, entity_code, factory_id, source_code, rate_limit_per_min, batch_row_limit, status, remark, create_time, update_time)
+SELECT 797403760988229, 'TESTKPARTNER', 'MDM_EMPLOYEE_HEADCOUNT', NULL, 'TEST_INBOUND_ERP', 60, 500, 1, 'WP-E 联调', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_inbound_grant WHERE access_key='TESTKPARTNER' AND entity_code='MDM_EMPLOYEE_HEADCOUNT');

+ 39 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.538.verify.sql

@@ -0,0 +1,39 @@
+-- 1.0.538.verify.sql — 每条 SELECT 必须返回真值
+
+SELECT COUNT(*) = 2
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'ItemMaster'
+   AND column_name IN ('source_system', 'source_updated_at');
+
+SELECT COUNT(*) = 2
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'CustMaster'
+   AND column_name IN ('source_system', 'source_updated_at');
+
+SELECT COUNT(*) = 2
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'SuppMaster'
+   AND column_name IN ('source_system', 'source_updated_at');
+
+SELECT COUNT(*) = 2
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'LocationMaster'
+   AND column_name IN ('source_system', 'source_updated_at');
+
+SELECT COUNT(*) = 2
+  FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+   AND table_name = 'EmployeeMaster'
+   AND column_name IN ('source_system', 'source_updated_at');
+
+SELECT COUNT(*) = 5
+  FROM mdp_entity
+ WHERE entity_code IN (
+   'S5_INVENTORY_TXN', 'S5_INVENTORY_OPENING_BALANCE',
+   'S7_FINISHED_ONHAND', 'S7_FINISHED_OPENING_BALANCE',
+   'MDM_EMPLOYEE_HEADCOUNT')
+   AND inbound_enabled = 1;

+ 151 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs

@@ -0,0 +1,151 @@
+using System.Security.Claims;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+using Microsoft.AspNetCore.Http;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>第三方标准 API 推数入站。类级仅指定 InboundSignature,不加裸 [Authorize]、不加 [AllowAnonymous]。</summary>
+[ApiController]
+[Route("api/mdp/inbound")]
+[NonUnify]
+[Authorize(AuthenticationSchemes = InboundSignatureDefaults.AuthenticationScheme)]
+[ApiDescriptionSettings(Order = 331, Description = "MDP API_INBOUND 推数入站")]
+public class MdpInboundController : ControllerBase
+{
+    private readonly MdpInboundReceiveService _receive;
+    private readonly MdpInboundSnapshotService _snapshots;
+
+    public MdpInboundController(MdpInboundReceiveService receive, MdpInboundSnapshotService snapshots)
+    {
+        _receive = receive;
+        _snapshots = snapshots;
+    }
+
+    /// <summary>接收推数。签名串 POST&amp;{path}&amp;...(方案 §5.2)。</summary>
+    [HttpPost("{entityCode}")]
+    public async Task<IActionResult> Receive(string entityCode, CancellationToken ct)
+    {
+        Request.EnableBuffering();
+        using var ms = new MemoryStream();
+        await Request.Body.CopyToAsync(ms);
+        Request.Body.Position = 0;
+
+        var outcome = await _receive.ReceiveAsync(new MdpInboundReceiveArgs
+        {
+            EntityCode = entityCode,
+            RawBody = ms.ToArray(),
+            AccessKey = AccessKey(),
+            TenantId = TenantId(),
+            IdempotencyKey = Header(InboundSignatureDefaults.IdempotencyKeyHeader),
+            ClientIp = ClientIp(),
+            Path = Request.Path.Value ?? string.Empty,
+            Headers = Request.Headers.ToDictionary(
+                h => h.Key,
+                h => h.Value.ToString(),
+                StringComparer.OrdinalIgnoreCase)
+        }, ct);
+        return JsonOutcome(outcome);
+    }
+
+    /// <summary>对外规范字段 JSON Schema。签名串 GET&amp;{path}&amp;...,须带与 POST 相同的签名头(空 body 摘要)。</summary>
+    [HttpGet("{entityCode}/schema")]
+    public async Task<IActionResult> Schema(string entityCode, CancellationToken ct)
+    {
+        var outcome = await _receive.SchemaAsync(entityCode, AccessKey(), TenantId(), ClientIp(), ct);
+        return JsonOutcome(outcome);
+    }
+
+    /// <summary>按批次查回执。非本 AccessKey 的批次返回 404。</summary>
+    [HttpGet("receipts/{syncBatchId}")]
+    public async Task<IActionResult> Receipt(string syncBatchId, CancellationToken ct)
+    {
+        var outcome = await _receive.ReceiptAsync(syncBatchId, AccessKey(), ct);
+        return JsonOutcome(outcome);
+    }
+
+    /// <summary>开启全量快照。同一 (tenant, access_key, entity) 已有 OPEN → 409。</summary>
+    [HttpPost("{entityCode}/snapshots")]
+    public async Task<IActionResult> OpenSnapshot(string entityCode, CancellationToken ct)
+    {
+        var outcome = await _snapshots.OpenAsync(entityCode, AccessKey(), TenantId(), ClientIp(), ct);
+        return JsonOutcome(outcome);
+    }
+
+    /// <summary>提交快照差集。?force=true 仅当状态为 DIFF_BLOCKED。</summary>
+    [HttpPost("{entityCode}/snapshots/{snapshotId}/commit")]
+    public async Task<IActionResult> CommitSnapshot(string entityCode, string snapshotId, [FromQuery] bool force, CancellationToken ct)
+    {
+        try
+        {
+            var outcome = await _snapshots.CommitAsync(
+                entityCode, snapshotId, AccessKey(), TenantId(), ClientIp(), force, ct);
+            return JsonOutcome(outcome);
+        }
+        catch (MdpInboundBatchException ex)
+        {
+            return JsonOutcome(new MdpInboundOutcome
+            {
+                HttpStatus = ex.HttpStatus,
+                Body = new { code = ex.HttpStatus, message = ex.Message, data = (object)null }
+            });
+        }
+    }
+
+    /// <summary>NDJSON 分块推送。每行独立 idempotencyKey,整包仍走一次入站签名。</summary>
+    [HttpPost("{entityCode}/bulk")]
+    public async Task<IActionResult> Bulk(string entityCode, CancellationToken ct)
+    {
+        Request.EnableBuffering();
+        using var ms = new MemoryStream();
+        await Request.Body.CopyToAsync(ms);
+        Request.Body.Position = 0;
+
+        var outcome = await _receive.BulkAsync(new MdpInboundReceiveArgs
+        {
+            EntityCode = entityCode,
+            RawBody = ms.ToArray(),
+            AccessKey = AccessKey(),
+            TenantId = TenantId(),
+            IdempotencyKey = Header(InboundSignatureDefaults.IdempotencyKeyHeader),
+            ClientIp = ClientIp(),
+            Path = Request.Path.Value ?? string.Empty,
+            Headers = Request.Headers.ToDictionary(
+                h => h.Key,
+                h => h.Value.ToString(),
+                StringComparer.OrdinalIgnoreCase)
+        }, ct);
+        return JsonOutcome(outcome);
+    }
+
+    /// <summary>日终对账。query 用 date 或 bizDate(yyyy-MM-dd)。</summary>
+    [HttpGet("{entityCode}/digest")]
+    public async Task<IActionResult> Digest(string entityCode, [FromQuery] string date, [FromQuery] string bizDate, CancellationToken ct)
+    {
+        var raw = !string.IsNullOrWhiteSpace(date) ? date : bizDate;
+        var outcome = await _receive.DigestAsync(entityCode, AccessKey(), TenantId(), ClientIp(), raw, ct);
+        return JsonOutcome(outcome);
+    }
+
+    private IActionResult JsonOutcome(MdpInboundOutcome outcome) =>
+        new JsonResult(outcome.Body, MdpInboundJson.Options) { StatusCode = outcome.HttpStatus };
+
+    private string AccessKey() =>
+        User.FindFirstValue(InboundSignatureDefaults.AccessKeyClaim) ?? string.Empty;
+
+    private long TenantId()
+    {
+        var raw = User.FindFirstValue(ClaimConst.TenantId);
+        return long.TryParse(raw, out var id) ? id : 0;
+    }
+
+    private string Header(string name) => Request.Headers[name].FirstOrDefault() ?? string.Empty;
+
+    private string ClientIp()
+    {
+        var xff = Request.Headers["X-Forwarded-For"].FirstOrDefault();
+        if (!string.IsNullOrWhiteSpace(xff))
+            return xff.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
+                   ?? string.Empty;
+        return HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty;
+    }
+}

+ 19 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureDefaults.cs

@@ -0,0 +1,19 @@
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+public static class InboundSignatureDefaults
+{
+    public const string AuthenticationScheme = "InboundSignature";
+
+    public const string AccessKeyHeader = "X-Access-Key";
+    public const string TimestampHeader = "X-Timestamp";
+    public const string NonceHeader = "X-Nonce";
+    public const string SignatureHeader = "X-Signature";
+    public const string IdempotencyKeyHeader = "Idempotency-Key";
+    public const string ContentSha256Header = "X-Content-SHA256";
+
+    public const string AccessKeyClaim = "access_key";
+    public const string FailMessageItemKey = "InboundSignature.FailMessage";
+
+    public const string MissingIdempotencyKey = "missing Idempotency-Key";
+    public const string ContentDigestMismatch = "content digest mismatch";
+}

+ 143 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureHandler.cs

@@ -0,0 +1,143 @@
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+using Admin.NET.Core;
+using Admin.NET.Core.Service;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>
+/// 插件侧入站验签:签名串含原始 body 摘要 + Idempotency-Key。不改框架 Core。
+/// </summary>
+public sealed class InboundSignatureHandler : AuthenticationHandler<InboundSignatureOptions>
+{
+    private readonly SysOpenAccessService _openAccess;
+    private readonly SysCacheService _cache;
+
+    public InboundSignatureHandler(
+        IOptionsMonitor<InboundSignatureOptions> options,
+        ILoggerFactory logger,
+        UrlEncoder encoder,
+        SysOpenAccessService openAccess,
+        SysCacheService cache)
+        : base(options, logger, encoder)
+    {
+        _openAccess = openAccess;
+        _cache = cache;
+    }
+
+    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
+    {
+        var accessKey = Header(InboundSignatureDefaults.AccessKeyHeader);
+        var timestampStr = Header(InboundSignatureDefaults.TimestampHeader);
+        var nonce = Header(InboundSignatureDefaults.NonceHeader);
+        var signature = Header(InboundSignatureDefaults.SignatureHeader);
+        var idempotencyKey = Header(InboundSignatureDefaults.IdempotencyKeyHeader);
+        var contentShaHeader = Header(InboundSignatureDefaults.ContentSha256Header);
+
+        if (string.IsNullOrWhiteSpace(idempotencyKey))
+            return Fail(InboundSignatureDefaults.MissingIdempotencyKey);
+        if (string.IsNullOrWhiteSpace(accessKey))
+            return Fail("missing X-Access-Key");
+        if (string.IsNullOrWhiteSpace(timestampStr))
+            return Fail("missing X-Timestamp");
+        if (string.IsNullOrWhiteSpace(nonce))
+            return Fail("missing X-Nonce");
+        if (string.IsNullOrWhiteSpace(signature))
+            return Fail("missing X-Signature");
+        if (string.IsNullOrWhiteSpace(contentShaHeader))
+            return Fail("missing X-Content-SHA256");
+
+        if (!long.TryParse(timestampStr, out var unixSec))
+            return Fail("invalid X-Timestamp");
+
+        var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+        if (Math.Abs(nowUnix - unixSec) > Options.AllowedDateDrift.TotalSeconds)
+            return Fail("timestamp outside allowed window");
+
+        Request.EnableBuffering();
+        using var ms = new MemoryStream();
+        await Request.Body.CopyToAsync(ms);
+        Request.Body.Position = 0;
+        var bodyBytes = ms.ToArray();
+        var contentSha256 = Convert.ToHexString(SHA256.HashData(bodyBytes)).ToLowerInvariant();
+        if (!string.Equals(contentSha256, contentShaHeader.Trim(), StringComparison.OrdinalIgnoreCase))
+            return Fail(InboundSignatureDefaults.ContentDigestMismatch);
+
+        SysOpenAccess openAccess;
+        try
+        {
+            openAccess = await _openAccess.GetByKey(accessKey);
+        }
+        catch (Exception ex)
+        {
+            Logger.LogWarning(ex, "InboundSignature GetByKey failed");
+            return Fail("unknown AccessKey");
+        }
+
+        if (openAccess == null || string.IsNullOrWhiteSpace(openAccess.AccessSecret))
+            return Fail("unknown AccessKey");
+
+        var path = Request.Path.Value ?? string.Empty;
+        // 接收 POST 与方案 §5.2 一致(POST&path&...);schema/receipts 等 GET 用实际方法名。
+        var method = (Request.Method ?? "POST").Trim().ToUpperInvariant();
+        var message = $"{method}&{path}&{accessKey}&{timestampStr}&{nonce}&{contentSha256}&{idempotencyKey}";
+        var expected = Sign(openAccess.AccessSecret, message);
+        var expectedBytes = Encoding.UTF8.GetBytes(expected);
+        var actualBytes = Encoding.UTF8.GetBytes(signature);
+        if (expectedBytes.Length != actualBytes.Length
+            || !CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes))
+            return Fail("invalid signature");
+
+        var nonceKey = $"AIDOP_INBOUND_NONCE:{accessKey}:{nonce}";
+        if (_cache.ExistKey(nonceKey))
+            return Fail("nonce replay");
+        _cache.Set(nonceKey, 1, Options.AllowedDateDrift * 2);
+
+        var identity = new ClaimsIdentity(InboundSignatureDefaults.AuthenticationScheme);
+        identity.AddClaim(new Claim(ClaimConst.TenantId, openAccess.BindTenantId.ToString()));
+        identity.AddClaim(new Claim(ClaimConst.UserId, openAccess.BindUserId.ToString()));
+        identity.AddClaim(new Claim(InboundSignatureDefaults.AccessKeyClaim, accessKey));
+        var ticket = new AuthenticationTicket(
+            new ClaimsPrincipal(identity),
+            InboundSignatureDefaults.AuthenticationScheme);
+        return AuthenticateResult.Success(ticket);
+    }
+
+    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
+    {
+        var result = await HandleAuthenticateOnceSafeAsync();
+        var message = result.Failure?.Message
+                      ?? Context.Items[InboundSignatureDefaults.FailMessageItemKey] as string
+                      ?? "unauthorized";
+        var status = IsBadRequest(message) ? 400 : 401;
+        if (Context.Response.HasStarted)
+            return;
+        Context.Response.StatusCode = status;
+        Context.Response.ContentType = "application/json; charset=utf-8";
+        await Context.Response.WriteAsync($"{{\"code\":{status},\"message\":{System.Text.Json.JsonSerializer.Serialize(message)},\"data\":null}}");
+    }
+
+    private AuthenticateResult Fail(string message)
+    {
+        Context.Items[InboundSignatureDefaults.FailMessageItemKey] = message;
+        return AuthenticateResult.Fail(message);
+    }
+
+    private string Header(string name) => Request.Headers[name].FirstOrDefault();
+
+    private static bool IsBadRequest(string message) =>
+        string.Equals(message, InboundSignatureDefaults.MissingIdempotencyKey, StringComparison.OrdinalIgnoreCase)
+        || string.Equals(message, InboundSignatureDefaults.ContentDigestMismatch, StringComparison.OrdinalIgnoreCase);
+
+    private static string Sign(string secret, string message)
+    {
+        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
+        return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(message)));
+    }
+}

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureOptions.cs

@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Authentication;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+public class InboundSignatureOptions : AuthenticationSchemeOptions
+{
+    public TimeSpan AllowedDateDrift { get; set; } = TimeSpan.FromMinutes(5);
+}

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

@@ -0,0 +1,332 @@
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Sales;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Supply;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>主数据 stg 批 COMMITTED 后回灌 S0 镜像。失败不阻断接收。</summary>
+public sealed class MdmMirrorUpsertService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly AdoS0ReferenceChecker _refs;
+    private readonly ILogger<MdmMirrorUpsertService> _logger;
+
+    public MdmMirrorUpsertService(
+        ISqlSugarClient db,
+        AdoS0ReferenceChecker refs,
+        ILogger<MdmMirrorUpsertService> logger)
+    {
+        _db = db;
+        _refs = refs;
+        _logger = logger;
+    }
+
+    public async Task MirrorCommittedAsync(
+        string entityCode,
+        long tenantId,
+        long? factoryId,
+        string sourceCode,
+        IReadOnlyList<MdpInboundPreparedRow> rows,
+        CancellationToken ct)
+    {
+        if (rows == null || rows.Count == 0)
+            return;
+        var factory = factoryId is > 0 ? factoryId.Value : 1L;
+        foreach (var row in rows)
+        {
+            try
+            {
+                var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+                if (code is "MDM_ITEM")
+                    await MirrorItemAsync(tenantId, factory, sourceCode, row, ct);
+                else if (code is "MDM_CUSTOMER")
+                    await MirrorCustomerAsync(tenantId, factory, sourceCode, row, ct);
+                else if (code is "MDM_SUPPLIER")
+                    await MirrorSupplierAsync(tenantId, factory, sourceCode, row, ct);
+                else if (code is "MDM_LOCATION")
+                    await MirrorLocationAsync(tenantId, factory, sourceCode, row, ct);
+                else if (code is "MDM_EMPLOYEE_HEADCOUNT")
+                    await MirrorEmployeeAsync(tenantId, factory, sourceCode, row, ct);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "inbound mirror failed entity={Entity} biz={Biz}", entityCode, row.BizKey);
+            }
+        }
+    }
+
+    private async Task MirrorItemAsync(
+        long tenantId, long factoryId, string sourceCode, MdpInboundPreparedRow row, CancellationToken ct)
+    {
+        var itemNum = Str(row.Dict, "ItemNum") ?? Str(row.Dict, "number");
+        if (string.IsNullOrWhiteSpace(itemNum))
+            return;
+
+        var incomingAt = ParseTime(row.SourceUpdatedAt);
+        var existing = await _db.Queryable<AdoS0ItemMaster>()
+            .Where(x => x.TenantId == tenantId && x.ItemNum == itemNum)
+            .FirstAsync(ct);
+
+        if (existing != null && string.IsNullOrWhiteSpace(existing.SourceSystem))
+        {
+            await InsertConflictAsync(tenantId, "MDM_ITEM", row.BizKey, sourceCode, row.RawJson, "ItemMaster", ct);
+            return;
+        }
+        if (existing?.SourceUpdatedAt is { } oldAt && incomingAt is { } neu && oldAt > neu)
+        {
+            _logger.LogInformation("inbound mirror skipped_stale item={Item}", itemNum);
+            return;
+        }
+
+        if (!string.IsNullOrWhiteSpace(Str(row.Dict, "Location")))
+            await _refs.LocationExistsAsync(tenantId, Str(row.Dict, "Location"));
+
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            await _db.Insertable(new AdoS0ItemMaster
+            {
+                TenantId = tenantId,
+                FactoryRefId = factoryId,
+                DomainCode = Str(row.Dict, "Domain"),
+                ItemNum = itemNum,
+                Descr = Str(row.Dict, "Descr") ?? Str(row.Dict, "name") ?? itemNum,
+                Drawing = Str(row.Dict, "Drawing") ?? Str(row.Dict, "model"),
+                UM = Str(row.Dict, "UM") ?? Str(row.Dict, "unit"),
+                ItemType = Str(row.Dict, "ItemType"),
+                Status = Str(row.Dict, "Status") ?? "normal",
+                IsActive = !IsInactive(Str(row.Dict, "Status") ?? Str(row.Dict, "is_active")),
+                SourceSystem = sourceCode,
+                SourceUpdatedAt = incomingAt,
+                CreateTime = now,
+                UpdateTime = now,
+                UpdateUser = "API_INBOUND"
+            }).ExecuteCommandAsync(ct);
+            return;
+        }
+
+        existing.Descr = Str(row.Dict, "Descr") ?? Str(row.Dict, "name") ?? existing.Descr;
+        existing.Drawing = Str(row.Dict, "Drawing") ?? Str(row.Dict, "model") ?? existing.Drawing;
+        existing.UM = Str(row.Dict, "UM") ?? Str(row.Dict, "unit") ?? existing.UM;
+        existing.ItemType = Str(row.Dict, "ItemType") ?? existing.ItemType;
+        existing.DomainCode = Str(row.Dict, "Domain") ?? existing.DomainCode;
+        if (!string.IsNullOrWhiteSpace(Str(row.Dict, "Status")))
+        {
+            existing.Status = Str(row.Dict, "Status");
+            existing.IsActive = !IsInactive(existing.Status);
+        }
+        existing.SourceSystem = sourceCode;
+        existing.SourceUpdatedAt = incomingAt ?? existing.SourceUpdatedAt;
+        existing.UpdateTime = now;
+        existing.UpdateUser = "API_INBOUND";
+        await _db.Updateable(existing)
+            .IgnoreColumns(x => new { x.Location, x.DefaultShelf, x.SafetyStk, x.LotSerialControl, x.AllocateSingleLot })
+            .ExecuteCommandAsync(ct);
+    }
+
+    private async Task MirrorCustomerAsync(
+        long tenantId, long factoryId, string sourceCode, MdpInboundPreparedRow row, CancellationToken ct)
+    {
+        var cust = Str(row.Dict, "Cust") ?? Str(row.Dict, "custom_no");
+        if (string.IsNullOrWhiteSpace(cust))
+            return;
+        var incomingAt = ParseTime(row.SourceUpdatedAt);
+        var existing = await _db.Queryable<AdoS0CustMaster>()
+            .Where(x => x.TenantId == tenantId && x.Cust == cust)
+            .FirstAsync(ct);
+        if (existing != null && string.IsNullOrWhiteSpace(existing.SourceSystem))
+        {
+            await InsertConflictAsync(tenantId, "MDM_CUSTOMER", row.BizKey, sourceCode, row.RawJson, "CustMaster", ct);
+            return;
+        }
+        if (existing?.SourceUpdatedAt is { } oldAt && incomingAt is { } neu && oldAt > neu)
+            return;
+
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            await _db.Insertable(new AdoS0CustMaster
+            {
+                TenantId = tenantId,
+                FactoryRefId = factoryId,
+                Cust = cust,
+                SortName = Str(row.Dict, "SortName") ?? Str(row.Dict, "custom_name"),
+                SourceSystem = sourceCode,
+                SourceUpdatedAt = incomingAt,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync(ct);
+            return;
+        }
+        existing.SortName = Str(row.Dict, "SortName") ?? existing.SortName;
+        existing.SourceSystem = sourceCode;
+        existing.SourceUpdatedAt = incomingAt ?? existing.SourceUpdatedAt;
+        existing.UpdateTime = now;
+        await _db.Updateable(existing).ExecuteCommandAsync(ct);
+    }
+
+    private async Task MirrorSupplierAsync(
+        long tenantId, long factoryId, string sourceCode, MdpInboundPreparedRow row, CancellationToken ct)
+    {
+        var supp = Str(row.Dict, "Supp") ?? Str(row.Dict, "supplier_number");
+        if (string.IsNullOrWhiteSpace(supp))
+            return;
+        var incomingAt = ParseTime(row.SourceUpdatedAt);
+        var existing = await _db.Queryable<AdoS0SuppMaster>()
+            .Where(x => x.TenantId == tenantId && x.Supp == supp)
+            .FirstAsync(ct);
+        if (existing != null && string.IsNullOrWhiteSpace(existing.SourceSystem))
+        {
+            await InsertConflictAsync(tenantId, "MDM_SUPPLIER", row.BizKey, sourceCode, row.RawJson, "SuppMaster", ct);
+            return;
+        }
+        if (existing?.SourceUpdatedAt is { } oldAt && incomingAt is { } neu && oldAt > neu)
+            return;
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            await _db.Insertable(new AdoS0SuppMaster
+            {
+                TenantId = tenantId,
+                FactoryRefId = factoryId,
+                Supp = supp,
+                SortName = Str(row.Dict, "SortName"),
+                SourceSystem = sourceCode,
+                SourceUpdatedAt = incomingAt,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync(ct);
+            return;
+        }
+        existing.SortName = Str(row.Dict, "SortName") ?? existing.SortName;
+        existing.SourceSystem = sourceCode;
+        existing.SourceUpdatedAt = incomingAt ?? existing.SourceUpdatedAt;
+        existing.UpdateTime = now;
+        await _db.Updateable(existing).ExecuteCommandAsync(ct);
+    }
+
+    private async Task MirrorLocationAsync(
+        long tenantId, long factoryId, string sourceCode, MdpInboundPreparedRow row, CancellationToken ct)
+    {
+        var loc = Str(row.Dict, "location") ?? Str(row.Dict, "Location");
+        if (string.IsNullOrWhiteSpace(loc))
+            return;
+        var incomingAt = ParseTime(row.SourceUpdatedAt);
+        var existing = await _db.Queryable<AdoS0LocationMaster>()
+            .Where(x => x.TenantId == tenantId && x.Location == loc)
+            .FirstAsync(ct);
+        if (existing != null && string.IsNullOrWhiteSpace(existing.SourceSystem))
+        {
+            await InsertConflictAsync(tenantId, "MDM_LOCATION", row.BizKey, sourceCode, row.RawJson, "LocationMaster", ct);
+            return;
+        }
+        if (existing?.SourceUpdatedAt is { } oldAt && incomingAt is { } neu && oldAt > neu)
+            return;
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            await _db.Insertable(new AdoS0LocationMaster
+            {
+                TenantId = tenantId,
+                FactoryRefId = factoryId,
+                DomainCode = Str(row.Dict, "Domain") ?? "",
+                Location = loc,
+                Descr = Str(row.Dict, "descr") ?? Str(row.Dict, "Descr"),
+                SourceSystem = sourceCode,
+                SourceUpdatedAt = incomingAt,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync(ct);
+            return;
+        }
+        existing.Descr = Str(row.Dict, "descr") ?? existing.Descr;
+        existing.SourceSystem = sourceCode;
+        existing.SourceUpdatedAt = incomingAt ?? existing.SourceUpdatedAt;
+        existing.UpdateTime = now;
+        await _db.Updateable(existing).ExecuteCommandAsync(ct);
+    }
+
+    private async Task MirrorEmployeeAsync(
+        long tenantId, long factoryId, string sourceCode, MdpInboundPreparedRow row, CancellationToken ct)
+    {
+        var emp = Str(row.Dict, "Employee") ?? Str(row.Dict, "employee");
+        if (string.IsNullOrWhiteSpace(emp))
+            return;
+        if (!string.IsNullOrWhiteSpace(Str(row.Dict, "Department")))
+            await _refs.DepartmentExistsAsync(tenantId, Str(row.Dict, "Department"));
+
+        var incomingAt = ParseTime(row.SourceUpdatedAt);
+        var existing = await _db.Queryable<AdoS0EmployeeMaster>()
+            .Where(x => x.TenantId == tenantId && x.Employee == emp)
+            .FirstAsync(ct);
+        if (existing != null && string.IsNullOrWhiteSpace(existing.SourceSystem))
+        {
+            await InsertConflictAsync(tenantId, "MDM_EMPLOYEE_HEADCOUNT", row.BizKey, sourceCode, row.RawJson, "EmployeeMaster", ct);
+            return;
+        }
+        if (existing?.SourceUpdatedAt is { } oldAt && incomingAt is { } neu && oldAt > neu)
+            return;
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            await _db.Insertable(new AdoS0EmployeeMaster
+            {
+                TenantId = tenantId,
+                FactoryRefId = factoryId,
+                DomainCode = Str(row.Dict, "Domain") ?? "",
+                Employee = emp,
+                Name = Str(row.Dict, "Name"),
+                SourceSystem = sourceCode,
+                SourceUpdatedAt = incomingAt,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync(ct);
+            return;
+        }
+        existing.Name = Str(row.Dict, "Name") ?? existing.Name;
+        existing.SourceSystem = sourceCode;
+        existing.SourceUpdatedAt = incomingAt ?? existing.SourceUpdatedAt;
+        existing.UpdateTime = now;
+        await _db.Updateable(existing).ExecuteCommandAsync(ct);
+    }
+
+    private async Task InsertConflictAsync(
+        long tenantId, string entityCode, string bizKey, string sourceCode, string raw, string table, CancellationToken ct)
+    {
+        var now = DateTime.Now;
+        await _db.Insertable(new MdpInboundConflict
+        {
+            TenantId = tenantId,
+            EntityCode = entityCode,
+            BizKey = bizKey ?? string.Empty,
+            SourceSystem = sourceCode,
+            IncomingRaw = raw ?? string.Empty,
+            MirrorTable = table,
+            ConflictType = "MANUAL_ROW_EXISTS",
+            Status = "PENDING",
+            CreateTime = now,
+            UpdateTime = now
+        }).ExecuteCommandAsync(ct);
+    }
+
+    private static string Str(IDictionary<string, object?> row, string key)
+    {
+        foreach (var kv in row)
+        {
+            if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
+                return kv.Value?.ToString();
+        }
+        return null;
+    }
+
+    private static DateTime? ParseTime(string raw) =>
+        DateTimeOffset.TryParse(raw, out var dto) ? dto.LocalDateTime : null;
+
+    private static bool IsInactive(string status) =>
+        string.Equals(status, "INACTIVE", StringComparison.OrdinalIgnoreCase)
+        || status == "0"
+        || string.Equals(status, "false", StringComparison.OrdinalIgnoreCase);
+}

+ 69 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundAuthService.cs

@@ -0,0 +1,69 @@
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>grant 授权 + IP 白名单 + 限流。行数上限由接收服务按 grant/entity 取 min。</summary>
+public sealed class MdpInboundAuthService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly SysCacheService _cache;
+
+    public MdpInboundAuthService(ISqlSugarClient db, SysCacheService cache)
+    {
+        _db = db;
+        _cache = cache;
+    }
+
+    /// <summary>返回 null = 通过;否则 (httpStatus, message)。</summary>
+    public async Task<(int Status, string Message)?> CheckAsync(
+        string accessKey, long tenantId, string entityCode, string clientIp, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var entity = await _db.Queryable<MdpEntity>()
+            .Where(e => e.InboundEnabled == 1)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .FirstAsync(ct);
+        if (entity == null)
+            return (403, "entity not authorized");
+
+        var grant = await _db.Queryable<MdpInboundGrant>()
+            .Where(g => g.AccessKey == accessKey && g.Status == 1)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .FirstAsync(ct);
+        if (grant == null)
+            return (403, "entity not authorized");
+
+        if (grant.TenantId != tenantId)
+            return (403, "entity not authorized");
+
+        if (!string.IsNullOrWhiteSpace(grant.IpAllowlist))
+        {
+            var allowed = grant.IpAllowlist
+                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+            var ip = (clientIp ?? string.Empty).Trim();
+            if (ip.Length == 0 || !allowed.Contains(ip, StringComparer.OrdinalIgnoreCase))
+                return (403, "ip not allowed");
+        }
+
+        var minute = DateTime.Now.ToString("yyyyMMddHHmm");
+        var rateKey = $"AIDOP_INBOUND_RATE:{accessKey}:{code}:{minute}";
+        var current = _cache.Get<int>(rateKey);
+        var next = current + 1;
+        _cache.Set(rateKey, next, TimeSpan.FromMinutes(1));
+        if (next > grant.RateLimitPerMin)
+            return (429, "rate limit exceeded");
+
+        return null;
+    }
+
+    public async Task<MdpInboundGrant> GetGrantAsync(string accessKey, string entityCode, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        return await _db.Queryable<MdpInboundGrant>()
+            .Where(g => g.AccessKey == accessKey && g.Status == 1)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .FirstAsync(ct);
+    }
+}

+ 199 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs

@@ -0,0 +1,199 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>
+/// mdp_field_map 两层映射:精确 access_key 覆盖 * 兜底;无映射行则恒等。
+/// 与 PULL 的 mdp_field_mapping 不是同一张表。
+/// </summary>
+public sealed class MdpInboundFieldMapper : ITransient
+{
+    private static readonly IReadOnlyDictionary<string, MdpInboundSchemaField[]> IdentityFields =
+        new Dictionary<string, MdpInboundSchemaField[]>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["S1_SALES_ORDER_ENTRY"] =
+            [
+                Req("bill_no"), Req("entry_seq"), Req("seorder_id"), Req("qty"),
+                Opt("item_number"), Opt("item_name"), Opt("specification"), Opt("unit"),
+                Opt("map_number"), Opt("map_name"), Opt("bom_number"),
+                Opt("deliver_notice_count"), Opt("deliver_count"),
+                Opt("price"), Opt("tax_price"), Opt("amount"), Opt("total_amount"),
+                Opt("plan_date"), Opt("date"), Opt("sys_capacity_date"), Opt("sys_material_date"),
+                Opt("planner_no"), Opt("planner_name"), Opt("custom_order_bill_no"),
+                Opt("progress"), Opt("urgent")
+            ],
+            ["MDM_ITEM"] =
+            [
+                Req("ItemNum"), Req("Descr"),
+                Opt("Drawing"), Opt("UM"), Opt("ItemType"), Opt("Status"), Opt("Domain")
+            ],
+            ["S5_INVENTORY_TXN"] =
+            [
+                Req("Id"), Opt("idid"), Opt("code"), Opt("sl"), Opt("slzx"),
+                Opt("shyn"), Opt("shtime"), Opt("date0"), Opt("lynoid"),
+                Opt("gdyn"), Opt("gdtime"), Opt("rwnoid"), Opt("jhdate"), Opt("addtime")
+            ],
+            ["S5_INVENTORY_OPENING_BALANCE"] =
+            [
+                Req("Id"), Opt("idid"), Opt("code"), Opt("sl"), Opt("slzx"),
+                Opt("shyn"), Opt("shtime"), Opt("date0")
+            ],
+            ["S7_FINISHED_ONHAND"] =
+            [
+                Req("Id"), Opt("code"), Opt("sl"), Opt("date0"), Opt("location")
+            ],
+            ["S7_FINISHED_OPENING_BALANCE"] =
+            [
+                Req("Id"), Opt("code"), Opt("sl"), Opt("date0"), Opt("location")
+            ],
+            ["MDM_EMPLOYEE_HEADCOUNT"] =
+            [
+                Req("Domain"), Req("Employee"),
+                Opt("Name"), Opt("Department")
+            ]
+        };
+
+    private static readonly MdpInboundSchemaField[] CommonContractFields =
+    [
+        Req("sourceUpdatedAt"),
+        Opt("sourceVersion"),
+        Opt("op")
+    ];
+
+    private readonly ISqlSugarClient _db;
+
+    public MdpInboundFieldMapper(ISqlSugarClient db) => _db = db;
+
+    public async Task<IReadOnlyList<MdpFieldMap>> LoadAsync(
+        long tenantId, string entityCode, string accessKey, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var rows = await _db.Queryable<MdpFieldMap>()
+            .Where(m => m.TenantId == tenantId && m.Status == 1)
+            .Where(m => m.AccessKey == accessKey || m.AccessKey == "*")
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .ToListAsync(ct);
+
+        var merged = new Dictionary<string, MdpFieldMap>(StringComparer.OrdinalIgnoreCase);
+        foreach (var row in rows.Where(r => r.AccessKey == "*"))
+            merged[row.ContractField] = row;
+        foreach (var row in rows.Where(r => r.AccessKey != "*"))
+            merged[row.ContractField] = row;
+        return merged.Values.ToList();
+    }
+
+    /// <summary>把对外规范字段改写成 raw 键;未登记字段原样保留。输出键名以映射表 raw_key 大小写为准。</summary>
+    public Dictionary<string, object?> MapRow(
+        IDictionary<string, object?> row, IReadOnlyList<MdpFieldMap> maps)
+    {
+        var byContract = maps.ToDictionary(m => m.ContractField, StringComparer.OrdinalIgnoreCase);
+        var output = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
+        foreach (var kv in row)
+        {
+            if (byContract.TryGetValue(kv.Key, out var map) && !string.IsNullOrWhiteSpace(map.RawKey))
+                output[map.RawKey] = kv.Value;
+            else
+                output[kv.Key] = kv.Value;
+        }
+        return output;
+    }
+
+    public List<string> MissingRequired(IDictionary<string, object?> mapped, IReadOnlyList<MdpFieldMap> maps)
+    {
+        var missing = new List<string>();
+        foreach (var map in maps.Where(m => m.IsRequired == 1))
+        {
+            var key = string.IsNullOrWhiteSpace(map.RawKey) ? map.ContractField : map.RawKey;
+            if (!TryGetNonEmpty(mapped, key))
+                missing.Add(map.ContractField);
+        }
+        return missing;
+    }
+
+    public MdpInboundSchemaData BuildSchema(
+        MdpEntity entity, IReadOnlyList<MdpFieldMap> maps)
+    {
+        var fields = new List<MdpInboundSchemaField>();
+        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        void Add(MdpInboundSchemaField f)
+        {
+            if (seen.Add(f.Name))
+                fields.Add(f);
+        }
+
+        if (maps.Count > 0)
+        {
+            foreach (var map in maps)
+            {
+                Add(new MdpInboundSchemaField
+                {
+                    Name = map.ContractField,
+                    RawKey = string.IsNullOrWhiteSpace(map.RawKey) ? map.ContractField : map.RawKey,
+                    Required = map.IsRequired == 1
+                });
+            }
+        }
+        else if (IdentityFields.TryGetValue(entity.EntityCode, out var identity))
+        {
+            foreach (var f in identity)
+                Add(f);
+        }
+        else if (!string.IsNullOrWhiteSpace(entity.BizKeyExpr))
+        {
+            foreach (var part in entity.BizKeyExpr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+                Add(Req(part));
+        }
+
+        foreach (var f in CommonContractFields)
+            Add(f);
+
+        return new MdpInboundSchemaData
+        {
+            EntityCode = entity.EntityCode,
+            BizKeyExpr = entity.BizKeyExpr,
+            Fields = fields
+        };
+    }
+
+    public static Dictionary<string, object?> ElementToDict(JsonElement el)
+    {
+        var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
+        if (el.ValueKind != JsonValueKind.Object)
+            return dict;
+        foreach (var prop in el.EnumerateObject())
+        {
+            dict[prop.Name] = prop.Value.ValueKind switch
+            {
+                JsonValueKind.Null => null,
+                JsonValueKind.String => prop.Value.GetString(),
+                JsonValueKind.Number => prop.Value.TryGetInt64(out var l) ? l
+                    : prop.Value.TryGetDecimal(out var d) ? d
+                    : prop.Value.GetDouble(),
+                JsonValueKind.True => true,
+                JsonValueKind.False => false,
+                _ => prop.Value.GetRawText()
+            };
+        }
+        return dict;
+    }
+
+    private static bool TryGetNonEmpty(IDictionary<string, object?> row, string key)
+    {
+        foreach (var kv in row)
+        {
+            if (!string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
+                continue;
+            if (kv.Value == null || kv.Value == DBNull.Value)
+                return false;
+            return !string.IsNullOrWhiteSpace(kv.Value.ToString());
+        }
+        return false;
+    }
+
+    private static MdpInboundSchemaField Req(string name) =>
+        new() { Name = name, RawKey = name, Required = true };
+
+    private static MdpInboundSchemaField Opt(string name) =>
+        new() { Name = name, RawKey = name, Required = false };
+}

+ 107 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundModels.cs

@@ -0,0 +1,107 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+internal static class MdpInboundJson
+{
+    public static readonly JsonSerializerOptions Options = new()
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+    };
+}
+
+public sealed class MdpInboundReceiveArgs
+{
+    public string EntityCode { get; init; } = string.Empty;
+    public byte[] RawBody { get; init; } = [];
+    public string AccessKey { get; init; } = string.Empty;
+    public long TenantId { get; init; }
+    public string IdempotencyKey { get; init; } = string.Empty;
+    public string ClientIp { get; init; } = string.Empty;
+    public string Path { get; init; } = string.Empty;
+    public IReadOnlyDictionary<string, string> Headers { get; init; }
+        = new Dictionary<string, string>();
+}
+
+public sealed class MdpInboundOutcome
+{
+    public int HttpStatus { get; init; }
+    public object Body { get; init; }
+}
+
+public sealed class MdpInboundReceiptData
+{
+    public string EntityCode { get; set; } = string.Empty;
+    public int Accepted { get; set; }
+    public int Rejected { get; set; }
+    public List<MdpInboundRejectedRow> RejectedRows { get; set; } = [];
+    public bool IdempotentReplay { get; set; }
+    public string SyncBatchId { get; set; } = string.Empty;
+    public long RequestId { get; set; }
+    public bool TransformEnqueued { get; set; }
+    public int StaleRejected { get; set; }
+    public int EqualTsOverride { get; set; }
+}
+
+public sealed class MdpInboundRejectedRow
+{
+    public string BizKey { get; set; }
+    public string Reason { get; set; } = string.Empty;
+}
+
+public sealed class MdpInboundSchemaField
+{
+    public string Name { get; set; } = string.Empty;
+    public string RawKey { get; set; } = string.Empty;
+    public bool Required { get; set; }
+}
+
+public sealed class MdpInboundSchemaData
+{
+    public string EntityCode { get; set; } = string.Empty;
+    public string BizKeyExpr { get; set; }
+    public List<MdpInboundSchemaField> Fields { get; set; } = [];
+}
+
+internal sealed class MdpInboundParsedBatch
+{
+    public List<JsonElement> Rows { get; init; } = [];
+    public string SnapshotId { get; init; }
+    public int? Seq { get; init; }
+}
+
+public sealed class MdpInboundPreparedRow
+{
+    public Dictionary<string, object?> Dict { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public string RawJson { get; init; } = string.Empty;
+    public string BizKey { get; init; } = string.Empty;
+    public string SourceUpdatedAt { get; init; }
+    public string SourceVersion { get; init; }
+}
+
+internal sealed class MdpInboundStgGuardRow
+{
+    public long Id { get; set; }
+    public string SourceSystem { get; set; }
+    public string SourceRowId { get; set; }
+    public string SourceUpdatedAt { get; set; }
+    public string SourceVersion { get; set; }
+    public string RawData { get; set; }
+}
+
+internal sealed class MdpInboundBatchException : Exception
+{
+    public int HttpStatus { get; }
+    public string ErrorCode { get; }
+
+    public MdpInboundBatchException(int httpStatus, string message, string errorCode = null)
+        : base(message)
+    {
+        HttpStatus = httpStatus;
+        ErrorCode = errorCode ?? message;
+    }
+}

+ 119 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundModuleTrigger.cs

@@ -0,0 +1,119 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>
+/// 入站成功后按实体触发模块重建。映射为显式静态表;未登记实体记 Warning 不阻断接收。
+/// </summary>
+public sealed class MdpInboundModuleTrigger : ITransient
+{
+    // 方案 §7.7:MDM 不属任何单一模块,推送成功后触发所有依赖模块
+    private static readonly IReadOnlyDictionary<string, string[]> EntityModuleMap =
+        new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["MDM_ITEM"] = ["S1", "S2", "S3", "S5", "S6"],
+            ["MDM_CUSTOMER"] = ["S1"],
+            ["MDM_SUPPLIER"] = ["S3", "S4", "S5"],
+            ["MDM_SOURCE_LIST"] = ["S3"],
+            ["MDM_LOCATION"] = ["S5", "S7"],
+            ["MDM_EMPLOYEE_HEADCOUNT"] = ["S9"]
+        };
+
+    private readonly ModuleRebuildService _rebuild;
+    private readonly ILogger<MdpInboundModuleTrigger> _logger;
+
+    public MdpInboundModuleTrigger(ModuleRebuildService rebuild, ILogger<MdpInboundModuleTrigger> logger)
+    {
+        _rebuild = rebuild;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 任一模块 202 入队或 409 去抖视为已加急。异常不抛给接收方。
+    /// factoryId:Rebuild 要求 &gt;0 且 ≠ tenantId,任务书字面 0 无法入队;grant 绑工厂优先生效,否则回落 1(与看板刷新一致)。
+    /// </summary>
+    public async Task<bool> EnqueueForEntityAsync(
+        string entityCode, long tenantId, long? grantFactoryId, CancellationToken ct)
+    {
+        var modules = ResolveModules(entityCode);
+        if (modules.Count == 0)
+            return false;
+
+        var factoryId = ResolveFactoryId(tenantId, grantFactoryId);
+        var enqueued = false;
+        foreach (var module in modules)
+        {
+            if (string.Equals(module, "S9", StringComparison.OrdinalIgnoreCase))
+            {
+                _logger.LogInformation("inbound trigger skip S9 (no rebuild handler) entity={Entity}", entityCode);
+                continue;
+            }
+
+            if (!MdpRebuildScope.RebuildModules.Contains(module))
+            {
+                _logger.LogWarning("inbound trigger skip unregistered module {Module} entity={Entity}", module, entityCode);
+                continue;
+            }
+
+            try
+            {
+                var (status, body) = await _rebuild.EnqueueAsync(
+                    module, tenantId, factoryId, requestedBy: null, triggerType: "INBOUND", ct);
+                if (status == 202)
+                {
+                    enqueued = true;
+                    _logger.LogInformation(
+                        "inbound trigger queued module={Module} entity={Entity} job={Job} tenant={Tenant} factory={Factory}",
+                        module, entityCode, body.JobId, tenantId, factoryId);
+                }
+                else if (status == 409)
+                {
+                    enqueued = true;
+                    _logger.LogInformation(
+                        "inbound trigger debounce module={Module} entity={Entity} status=409 {Message}",
+                        module, entityCode, body.Message);
+                }
+                else
+                {
+                    _logger.LogWarning(
+                        "inbound trigger not accepted module={Module} entity={Entity} status={Status} {Message}",
+                        module, entityCode, status, body.Message);
+                }
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "inbound trigger failed module={Module} entity={Entity}", module, entityCode);
+            }
+        }
+
+        return enqueued;
+    }
+
+    private List<string> ResolveModules(string entityCode)
+    {
+        if (string.IsNullOrWhiteSpace(entityCode))
+            return [];
+        if (EntityModuleMap.TryGetValue(entityCode, out var mapped))
+            return mapped.ToList();
+
+        var prefix = entityCode.Trim();
+        var us = prefix.IndexOf('_');
+        if (us > 0)
+            prefix = prefix[..us];
+        prefix = prefix.ToUpperInvariant();
+        if (MdpRebuildScope.RebuildModules.Contains(prefix))
+            return [prefix];
+
+        _logger.LogWarning("inbound trigger: entity {Entity} not in map and prefix {Prefix} is not a rebuild module",
+            entityCode, prefix);
+        return [];
+    }
+
+    private static long ResolveFactoryId(long tenantId, long? grantFactoryId)
+    {
+        if (grantFactoryId is > 0 && grantFactoryId.Value != tenantId)
+            return grantFactoryId.Value;
+        return 1;
+    }
+}

+ 955 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundReceiveService.cs

@@ -0,0 +1,955 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>入站接收主流水线。步骤顺序按任务书 §6.3 定死,勿换序。</summary>
+public sealed class MdpInboundReceiveService : ITransient
+{
+    private static readonly Regex TableNameRe = new(@"^[A-Za-z0-9_]+$", RegexOptions.Compiled);
+    private static readonly Regex IsoOffsetRe = new(@"[Zz]|[+\-]\d{2}:\d{2}", RegexOptions.Compiled);
+    private const int MaxRowBytes = 64 * 1024;
+    private static readonly TimeSpan InProgressWindow = TimeSpan.FromMinutes(10);
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpInboundAuthService _auth;
+    private readonly MdpInboundFieldMapper _mapper;
+    private readonly MdpStagingWriter _writer;
+    private readonly MdpInboundModuleTrigger _trigger;
+    private readonly MdpInboundSnapshotService _snapshots;
+    private readonly MdmMirrorUpsertService _mirror;
+    private readonly ILogger<MdpInboundReceiveService> _logger;
+
+    public MdpInboundReceiveService(
+        ISqlSugarClient db,
+        MdpInboundAuthService auth,
+        MdpInboundFieldMapper mapper,
+        MdpStagingWriter writer,
+        MdpInboundModuleTrigger trigger,
+        MdpInboundSnapshotService snapshots,
+        MdmMirrorUpsertService mirror,
+        ILogger<MdpInboundReceiveService> logger)
+    {
+        _db = db;
+        _auth = auth;
+        _mapper = mapper;
+        _writer = writer;
+        _trigger = trigger;
+        _snapshots = snapshots;
+        _mirror = mirror;
+        _logger = logger;
+    }
+
+    public async Task<MdpInboundOutcome> ReceiveAsync(MdpInboundReceiveArgs args, CancellationToken ct)
+    {
+        var sw = System.Diagnostics.Stopwatch.StartNew();
+        var entityCode = (args.EntityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var hash = ToSha256Hex(args.RawBody);
+
+        MdpEntity entity;
+        MdpInboundGrant grant;
+        try
+        {
+            entity = await LoadInboundEntityAsync(entityCode, ct)
+                     ?? throw new MdpInboundBatchException(403, "entity not authorized");
+
+            var denied = await _auth.CheckAsync(args.AccessKey, args.TenantId, entityCode, args.ClientIp, ct);
+            if (denied != null)
+                return Fail(denied.Value.Status, denied.Value.Message);
+
+            grant = await _auth.GetGrantAsync(args.AccessKey, entityCode, ct)
+                    ?? throw new MdpInboundBatchException(403, "entity not authorized");
+        }
+        catch (MdpInboundBatchException ex)
+        {
+            return Fail(ex.HttpStatus, ex.Message);
+        }
+
+        MdpInboundParsedBatch batch;
+        try
+        {
+            batch = ParseBatch(args.RawBody, grant, entity);
+        }
+        catch (MdpInboundBatchException ex)
+        {
+            return Fail(ex.HttpStatus, ex.Message);
+        }
+        catch (JsonException)
+        {
+            return Fail(400, "invalid json");
+        }
+
+        var source = await _db.Queryable<MdpSource>()
+            .Where(s => s.SourceCode == grant.SourceCode && s.Status == 1)
+            .FirstAsync(ct);
+        if (source == null)
+            return Fail(500, "inbound source not configured");
+
+        var sourceTable = string.IsNullOrWhiteSpace(entity.SourceTableName)
+            ? entity.EntityCode
+            : entity.SourceTableName!;
+
+        try
+        {
+            await _snapshots.EnsureAcceptAsync(
+                batch.SnapshotId, args.AccessKey, args.TenantId, entity.EntityCode, batch.Seq, ct);
+        }
+        catch (MdpInboundBatchException ex)
+        {
+            return Fail(ex.HttpStatus, ex.Message);
+        }
+
+        MdpInboundRequest request;
+        try
+        {
+            request = await BeginRequestAsync(args, entity, hash, batch.SnapshotId, ct);
+        }
+        catch (MdpInboundBatchException ex)
+        {
+            return Fail(ex.HttpStatus, ex.Message);
+        }
+
+        if (request.Status == "COMMITTED" &&
+            string.Equals(request.RequestHash, hash, StringComparison.OrdinalIgnoreCase) &&
+            !string.IsNullOrWhiteSpace(request.ReceiptJson))
+        {
+            return Receipt(request.ReceiptJson, replay: true);
+        }
+
+        await TryArchiveEnvelopeAsync(args, request, hash, ct);
+
+        try
+        {
+            var maps = await _mapper.LoadAsync(args.TenantId, entity.EntityCode, args.AccessKey, ct);
+            var accepted = new List<MdpInboundPreparedRow>();
+            var rejected = new List<MdpInboundRejectedRow>();
+            var staleRejected = 0;
+            var equalTsOverride = 0;
+
+            foreach (var el in batch.Rows)
+            {
+                var prepared = PrepareRow(el, entity, maps, grant, args.TenantId, rejected, out var batchDeny);
+                if (batchDeny != null)
+                {
+                    await MarkFailedAsync(request.Id, batchDeny.Message, sw.ElapsedMilliseconds, ct);
+                    return Fail(batchDeny.HttpStatus, batchDeny.Message);
+                }
+                if (prepared == null)
+                    continue;
+
+                if (entity.StaleGuardEnabled == 1)
+                {
+                    var decision = await GuardStaleAsync(
+                        entity, args.TenantId, sourceTable, prepared, grant.SourceCode, ct);
+                    if (decision.RejectStale)
+                    {
+                        staleRejected++;
+                        rejected.Add(new MdpInboundRejectedRow { BizKey = prepared.BizKey, Reason = "stale" });
+                        continue;
+                    }
+                    if (decision.EqualTsOverride)
+                        equalTsOverride++;
+                    prepared = decision.Row ?? prepared;
+                }
+
+                accepted.Add(prepared);
+            }
+
+            var ctx = new MdpPullContext { TenantId = args.TenantId, BatchId = request.SyncBatchId };
+            var written = new List<MdpInboundPreparedRow>();
+            var tran = await _db.Ado.UseTranAsync(async () =>
+            {
+                foreach (var row in accepted)
+                {
+                    await TransferIfNeededAsync(
+                        entity, args.TenantId, sourceTable, row.BizKey, grant.SourceCode, ct);
+                    var n = await _writer.UpsertAsync(
+                        source, entity, sourceTable, row.Dict, row.RawJson, row.BizKey, ctx);
+                    if (n == 0)
+                    {
+                        rejected.Add(new MdpInboundRejectedRow
+                        {
+                            BizKey = row.BizKey,
+                            Reason = "tenant_unresolved"
+                        });
+                    }
+                    else
+                    {
+                        written.Add(row);
+                    }
+                }
+
+                var data = new MdpInboundReceiptData
+                {
+                    EntityCode = entity.EntityCode,
+                    Accepted = written.Count,
+                    Rejected = rejected.Count,
+                    RejectedRows = rejected,
+                    IdempotentReplay = false,
+                    SyncBatchId = request.SyncBatchId,
+                    RequestId = request.Id,
+                    TransformEnqueued = false,
+                    StaleRejected = staleRejected,
+                    EqualTsOverride = equalTsOverride
+                };
+                var resultCode = rejected.Count > 0 ? 2 : 0;
+                var body = Envelope(resultCode, resultCode == 2 ? "partial" : "ok", data);
+                var receiptJson = JsonSerializer.Serialize(body, MdpInboundJson.Options);
+                var now = DateTime.Now;
+                await _db.Updateable<MdpInboundRequest>()
+                    .SetColumns(r => new MdpInboundRequest
+                    {
+                        Status = "COMMITTED",
+                        HttpStatus = 202,
+                        ResultCode = resultCode,
+                        Accepted = written.Count,
+                        Rejected = rejected.Count,
+                        StaleRejected = staleRejected,
+                        EqualTsOverride = equalTsOverride,
+                        ReceiptJson = receiptJson,
+                        ErrorMsg = null,
+                        ElapsedMs = (int)sw.ElapsedMilliseconds,
+                        UpdateTime = now
+                    })
+                    .Where(r => r.Id == request.Id)
+                    .ExecuteCommandAsync(ct);
+                if (!string.IsNullOrWhiteSpace(batch.SnapshotId) && batch.Seq is > 0)
+                    await _snapshots.AdvanceAsync(batch.SnapshotId, batch.Seq.Value, written.Count, ct);
+            });
+
+            if (!tran.IsSuccess)
+            {
+                var err = tran.ErrorException?.Message ?? "transaction failed";
+                await MarkFailedAsync(request.Id, Truncate(err, 1000), sw.ElapsedMilliseconds, ct);
+                _logger.LogError(tran.ErrorException, "inbound write transaction failed requestId={Id}", request.Id);
+                return Fail(500, "inbound write failed");
+            }
+
+            var committed = await _db.Queryable<MdpInboundRequest>()
+                .Where(r => r.Id == request.Id)
+                .FirstAsync(ct);
+            var receiptJson = committed?.ReceiptJson;
+            try
+            {
+                await _mirror.MirrorCommittedAsync(
+                    entity.EntityCode, args.TenantId, grant.FactoryId, grant.SourceCode, written, ct);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "inbound mirror after commit failed requestId={Id}", request.Id);
+            }
+
+            var enqueued = false;
+            try
+            {
+                enqueued = await _trigger.EnqueueForEntityAsync(
+                    entity.EntityCode, args.TenantId, grant.FactoryId, ct);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "inbound trigger after commit failed requestId={Id}", request.Id);
+            }
+
+            if (enqueued)
+                receiptJson = await PatchTransformEnqueuedAsync(request.Id, receiptJson, ct);
+            return Receipt(receiptJson, replay: false);
+        }
+        catch (Exception ex)
+        {
+            await MarkFailedAsync(request.Id, Truncate(ex.Message, 1000), sw.ElapsedMilliseconds, ct);
+            _logger.LogError(ex, "inbound receive failed requestId={Id}", request.Id);
+            return Fail(500, "inbound receive failed");
+        }
+    }
+
+    public async Task<MdpInboundOutcome> SchemaAsync(
+        string entityCode, string accessKey, long tenantId, string clientIp, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
+        if (denied != null)
+            return Fail(denied.Value.Status, denied.Value.Message);
+
+        var entity = await LoadInboundEntityAsync(code, ct);
+        if (entity == null)
+            return Fail(403, "entity not authorized");
+
+        var maps = await _mapper.LoadAsync(tenantId, entity.EntityCode, accessKey, ct);
+        return Ok(_mapper.BuildSchema(entity, maps));
+    }
+
+    public async Task<MdpInboundOutcome> ReceiptAsync(
+        string syncBatchId, string accessKey, CancellationToken ct)
+    {
+        var row = await _db.Queryable<MdpInboundRequest>()
+            .Where(r => r.SyncBatchId == syncBatchId && r.AccessKey == accessKey)
+            .FirstAsync(ct);
+        if (row == null)
+            return Fail(404, "receipt not found");
+
+        object receipt = null;
+        if (!string.IsNullOrWhiteSpace(row.ReceiptJson))
+        {
+            try
+            {
+                receipt = JsonSerializer.Deserialize<object>(row.ReceiptJson);
+            }
+            catch (JsonException)
+            {
+                receipt = row.ReceiptJson;
+            }
+        }
+
+        return Ok(new
+        {
+            status = row.Status,
+            accepted = row.Accepted,
+            rejected = row.Rejected,
+            staleRejected = row.StaleRejected,
+            receiptJson = receipt
+        });
+    }
+
+    /// <summary>NDJSON 分块:每行须为合法接收报文,并带根级 idempotencyKey。</summary>
+    public async Task<MdpInboundOutcome> BulkAsync(MdpInboundReceiveArgs args, CancellationToken ct)
+    {
+        var text = Encoding.UTF8.GetString(args.RawBody ?? []);
+        var lines = text.Replace("\r\n", "\n").Split('\n');
+        var nonempty = lines.Count(l => !string.IsNullOrWhiteSpace(l));
+        if (nonempty == 0)
+            return Fail(400, "empty bulk");
+        if (nonempty > 100)
+            return Fail(413, "bulk line limit exceeded");
+
+        var chunks = new List<object>();
+        var lineNo = 0;
+        foreach (var rawLine in lines)
+        {
+            var line = rawLine.Trim();
+            if (string.IsNullOrEmpty(line))
+                continue;
+            lineNo++;
+            string idem;
+            try
+            {
+                using var doc = JsonDocument.Parse(line);
+                if (doc.RootElement.ValueKind != JsonValueKind.Object
+                    || !TryGetPropertyIgnoreCase(doc.RootElement, "idempotencyKey", out var keyEl)
+                    || keyEl.ValueKind != JsonValueKind.String
+                    || string.IsNullOrWhiteSpace(keyEl.GetString()))
+                {
+                    chunks.Add(new { line = lineNo, httpStatus = 400, message = "missing line idempotencyKey" });
+                    continue;
+                }
+                idem = keyEl.GetString();
+            }
+            catch (JsonException)
+            {
+                chunks.Add(new { line = lineNo, httpStatus = 400, message = "invalid json" });
+                continue;
+            }
+
+            var outcome = await ReceiveAsync(new MdpInboundReceiveArgs
+            {
+                EntityCode = args.EntityCode,
+                RawBody = Encoding.UTF8.GetBytes(line),
+                AccessKey = args.AccessKey,
+                TenantId = args.TenantId,
+                IdempotencyKey = idem,
+                ClientIp = args.ClientIp,
+                Path = args.Path,
+                Headers = args.Headers
+            }, ct);
+            chunks.Add(new { line = lineNo, httpStatus = outcome.HttpStatus, body = outcome.Body });
+        }
+
+        return new MdpInboundOutcome
+        {
+            HttpStatus = 202,
+            Body = Envelope(0, "ok", new { chunks })
+        };
+    }
+
+    /// <summary>日终对账:扫当日请求 + 按 sync_batch_id 取 stg 键,字典序拼接后 sha256。首期不加新索引。</summary>
+    public async Task<MdpInboundOutcome> DigestAsync(
+        string entityCode, string accessKey, long tenantId, string clientIp, string dateRaw, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
+        if (denied != null)
+            return Fail(denied.Value.Status, denied.Value.Message);
+
+        if (!DateTime.TryParse(dateRaw, out var parsed))
+            return Fail(400, "date required (yyyy-MM-dd)");
+        var from = parsed.Date;
+        var to = from.AddDays(1);
+
+        var entity = await LoadInboundEntityAsync(code, ct);
+        if (entity == null)
+            return Fail(403, "entity not authorized");
+
+        var reqs = await _db.Queryable<MdpInboundRequest>()
+            .Where(r => r.AccessKey == accessKey && r.CreateTime >= from && r.CreateTime < to)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .ToListAsync(ct);
+
+        var accepted = reqs.Where(r => r.Status == "COMMITTED").Sum(r => r.Accepted);
+        var rejected = reqs.Sum(r => r.Rejected);
+        var batchIds = reqs
+            .Where(r => r.Status == "COMMITTED" && !string.IsNullOrWhiteSpace(r.SyncBatchId))
+            .Select(r => r.SyncBatchId)
+            .Distinct()
+            .ToList();
+
+        var keys = new List<string>();
+        if (batchIds.Count > 0
+            && !string.IsNullOrWhiteSpace(entity.TargetTableName)
+            && TableNameRe.IsMatch(entity.TargetTableName))
+        {
+            keys = await _db.Ado.SqlQueryAsync<string>(
+                $"""
+                SELECT source_biz_key FROM `{entity.TargetTableName}`
+                 WHERE tenant_id=@t AND sync_batch_id IN ({string.Join(",", batchIds.Select((_, i) => "@b" + i))})
+                """,
+                new[] { new SugarParameter("@t", tenantId) }
+                    .Concat(batchIds.Select((b, i) => new SugarParameter("@b" + i, b)))
+                    .ToArray());
+        }
+
+        var sorted = keys
+            .Where(k => !string.IsNullOrWhiteSpace(k))
+            .Distinct(StringComparer.Ordinal)
+            .OrderBy(k => k, StringComparer.Ordinal)
+            .ToList();
+        var digest = ToSha256Hex(Encoding.UTF8.GetBytes(string.Join("\n", sorted)));
+
+        return Ok(new
+        {
+            entityCode = code,
+            date = from.ToString("yyyy-MM-dd"),
+            requestCount = reqs.Count,
+            accepted,
+            rejected,
+            committedBatches = batchIds.Count,
+            bizKeyCount = sorted.Count,
+            bizKeyDigest = digest,
+            sort = "ordinal-asc join \\n then sha256-hex"
+        });
+    }
+
+    private async Task<MdpEntity> LoadInboundEntityAsync(string entityCode, CancellationToken ct)
+    {
+        return await _db.Queryable<MdpEntity>()
+            .Where(e => e.InboundEnabled == 1)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entityCode))
+            .FirstAsync(ct);
+    }
+
+    private static MdpInboundParsedBatch ParseBatch(byte[] rawBody, MdpInboundGrant grant, MdpEntity entity)
+    {
+        using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(rawBody));
+        var root = doc.RootElement;
+        JsonElement listEl;
+        string snapshotId = null;
+        int? parsedSeq = null;
+
+        if (root.ValueKind == JsonValueKind.Array)
+        {
+            listEl = root;
+        }
+        else if (root.ValueKind == JsonValueKind.Object
+                 && TryGetPropertyIgnoreCase(root, "data", out var data)
+                 && data.ValueKind == JsonValueKind.Object
+                 && TryGetPropertyIgnoreCase(data, "list", out var list)
+                 && list.ValueKind == JsonValueKind.Array)
+        {
+            listEl = list;
+            if (TryGetPropertyIgnoreCase(data, "snapshotId", out var snap) && snap.ValueKind == JsonValueKind.String)
+                snapshotId = snap.GetString();
+            if (TryGetPropertyIgnoreCase(data, "seq", out var seqEl)
+                && seqEl.ValueKind == JsonValueKind.Number
+                && seqEl.TryGetInt32(out var seqVal))
+                parsedSeq = seqVal;
+        }
+        else
+        {
+            throw new MdpInboundBatchException(400, "invalid payload: expect data.list or root array");
+        }
+
+        var rows = new List<JsonElement>();
+        foreach (var item in listEl.EnumerateArray())
+        {
+            if (item.ValueKind != JsonValueKind.Object)
+                throw new MdpInboundBatchException(400, "invalid payload: row must be object");
+            AssertNoCaseDuplicateKeys(item);
+            var raw = item.GetRawText();
+            if (Encoding.UTF8.GetByteCount(raw) > MaxRowBytes)
+                throw new MdpInboundBatchException(413, "row too large");
+            rows.Add(item.Clone());
+        }
+
+        var limit = Math.Min(
+            grant.BatchRowLimit > 0 ? grant.BatchRowLimit : 500,
+            entity.InboundMaxRows > 0 ? entity.InboundMaxRows : 500);
+        if (rows.Count > limit)
+            throw new MdpInboundBatchException(413, "row limit exceeded");
+
+        return new MdpInboundParsedBatch { Rows = rows, SnapshotId = snapshotId, Seq = parsedSeq };
+    }
+
+    private static void AssertNoCaseDuplicateKeys(JsonElement obj)
+    {
+        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        foreach (var prop in obj.EnumerateObject())
+        {
+            if (!seen.Add(prop.Name))
+                throw new MdpInboundBatchException(400, "duplicate key");
+        }
+    }
+
+    private async Task<MdpInboundRequest> BeginRequestAsync(
+        MdpInboundReceiveArgs args, MdpEntity entity, string hash, string snapshotId, CancellationToken ct)
+    {
+        var now = DateTime.Now;
+        var draft = new MdpInboundRequest
+        {
+            TenantId = args.TenantId,
+            AccessKey = args.AccessKey,
+            EntityCode = entity.EntityCode,
+            IdempotencyKey = args.IdempotencyKey,
+            RequestHash = hash,
+            SyncBatchId = "PENDING",
+            SnapshotId = snapshotId,
+            Status = "RECEIVED",
+            ClientIp = args.ClientIp,
+            CreateTime = now,
+            UpdateTime = now
+        };
+
+        try
+        {
+            var id = await _db.Insertable(draft).ExecuteReturnBigIdentityAsync();
+            draft.Id = id;
+            draft.SyncBatchId = $"INB_{now:yyyyMMdd}_{id:D6}";
+            await _db.Updateable<MdpInboundRequest>()
+                .SetColumns(r => new MdpInboundRequest { SyncBatchId = draft.SyncBatchId, UpdateTime = now })
+                .Where(r => r.Id == id)
+                .ExecuteCommandAsync(ct);
+            return draft;
+        }
+        catch (Exception ex) when (IsDuplicateKey(ex))
+        {
+            var existing = await _db.Queryable<MdpInboundRequest>()
+                .Where(r => r.TenantId == args.TenantId
+                            && r.AccessKey == args.AccessKey
+                            && r.IdempotencyKey == args.IdempotencyKey)
+                .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entity.EntityCode.ToUpperInvariant()))
+                .FirstAsync(ct)
+                        ?? throw new MdpInboundBatchException(409, "in-progress");
+
+            if (string.Equals(existing.Status, "COMMITTED", StringComparison.OrdinalIgnoreCase))
+            {
+                if (!string.Equals(existing.RequestHash, hash, StringComparison.OrdinalIgnoreCase))
+                    throw new MdpInboundBatchException(409, "idempotency key conflict");
+                return existing;
+            }
+
+            if (string.Equals(existing.Status, "RECEIVED", StringComparison.OrdinalIgnoreCase)
+                && existing.UpdateTime > now - InProgressWindow)
+                throw new MdpInboundBatchException(409, "in-progress");
+
+            var n = await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE mdp_inbound_request
+                   SET status='RECEIVED', request_hash=@h, snapshot_id=@snap,
+                       error_msg=NULL, update_time=@now
+                 WHERE id=@id AND status=@old
+                """,
+                new SugarParameter("@h", hash),
+                new SugarParameter("@snap", (object)snapshotId ?? DBNull.Value),
+                new SugarParameter("@now", now),
+                new SugarParameter("@id", existing.Id),
+                new SugarParameter("@old", existing.Status));
+            if (n != 1)
+                throw new MdpInboundBatchException(409, "in-progress");
+
+            existing.Status = "RECEIVED";
+            existing.RequestHash = hash;
+            existing.SnapshotId = snapshotId;
+            existing.UpdateTime = now;
+            if (string.IsNullOrWhiteSpace(existing.SyncBatchId) || existing.SyncBatchId == "PENDING")
+            {
+                existing.SyncBatchId = $"INB_{now:yyyyMMdd}_{existing.Id:D6}";
+                await _db.Updateable<MdpInboundRequest>()
+                    .SetColumns(r => new MdpInboundRequest { SyncBatchId = existing.SyncBatchId })
+                    .Where(r => r.Id == existing.Id)
+                    .ExecuteCommandAsync(ct);
+            }
+            return existing;
+        }
+    }
+
+    private async Task TryArchiveEnvelopeAsync(
+        MdpInboundReceiveArgs args, MdpInboundRequest request, string hash, CancellationToken ct)
+    {
+        try
+        {
+            var exists = await _db.Queryable<MdpInboundEnvelope>()
+                .Where(e => e.RequestId == request.Id)
+                .AnyAsync(ct);
+            if (exists)
+                return;
+
+            var headers = args.Headers
+                .Where(kv => !IsSensitiveHeader(kv.Key))
+                .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
+            await _db.Insertable(new MdpInboundEnvelope
+            {
+                RequestId = request.Id,
+                TenantId = args.TenantId,
+                AccessKey = args.AccessKey,
+                EntityCode = request.EntityCode,
+                Path = args.Path,
+                HeadersJson = JsonSerializer.Serialize(headers, MdpInboundJson.Options),
+                Body = Encoding.UTF8.GetString(args.RawBody),
+                BodySha256 = hash,
+                CreateTime = DateTime.Now
+            }).ExecuteCommandAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "inbound envelope archive failed requestId={Id}", request.Id);
+        }
+    }
+
+    private MdpInboundPreparedRow PrepareRow(
+        JsonElement el,
+        MdpEntity entity,
+        IReadOnlyList<MdpFieldMap> maps,
+        MdpInboundGrant grant,
+        long boundTenantId,
+        List<MdpInboundRejectedRow> rejected,
+        out MdpInboundBatchException batchDeny)
+    {
+        batchDeny = null;
+        var incoming = MdpInboundFieldMapper.ElementToDict(el);
+        var mapped = _mapper.MapRow(incoming, maps);
+        var missing = _mapper.MissingRequired(mapped, maps);
+        var previewKey = TryGetString(mapped, "bizKey");
+
+        if (missing.Count > 0)
+        {
+            rejected.Add(new MdpInboundRejectedRow
+            {
+                BizKey = previewKey,
+                Reason = "missing required field " + string.Join(",", missing)
+            });
+            return null;
+        }
+
+        var sourceUpdatedAt = TryGetString(mapped, "sourceUpdatedAt");
+        if (!HasIsoOffset(sourceUpdatedAt))
+        {
+            rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "sourceUpdatedAt required with timezone offset" });
+            return null;
+        }
+
+        var sourceVersion = TryGetString(mapped, "sourceVersion");
+        if (!string.IsNullOrWhiteSpace(sourceVersion) && !long.TryParse(sourceVersion, out _))
+        {
+            rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "sourceVersion must be long" });
+            return null;
+        }
+
+        if (TryGetLong(mapped, "tenant_id", out var rowTenant) || TryGetLong(mapped, "TenantId", out rowTenant))
+        {
+            if (rowTenant != boundTenantId)
+            {
+                batchDeny = new MdpInboundBatchException(403, "tenant mismatch");
+                return null;
+            }
+        }
+        mapped["tenant_id"] = boundTenantId;
+
+        if (grant.FactoryId is > 0)
+        {
+            if (!TryGetLong(mapped, "factory_id", out var rowFactory) && !TryGetLong(mapped, "FactoryId", out rowFactory))
+            {
+                rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "factory mismatch" });
+                return null;
+            }
+            if (rowFactory != grant.FactoryId.Value)
+            {
+                rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "factory mismatch" });
+                return null;
+            }
+        }
+
+        var bizKey = MdpStagingWriter.BuildBizKey(entity.BizKeyExpr, mapped);
+        if (string.IsNullOrWhiteSpace(bizKey))
+        {
+            rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "missing biz key fields" });
+            return null;
+        }
+        if (!string.IsNullOrWhiteSpace(previewKey) && !string.Equals(previewKey, bizKey, StringComparison.Ordinal))
+        {
+            rejected.Add(new MdpInboundRejectedRow { BizKey = previewKey, Reason = "bizKey mismatch" });
+            return null;
+        }
+
+        var op = TryGetString(mapped, "op");
+        if (string.IsNullOrWhiteSpace(op))
+            op = "upsert";
+        if (string.Equals(op, "delete", StringComparison.OrdinalIgnoreCase))
+        {
+            if (!DeleteSupported(entity))
+            {
+                rejected.Add(new MdpInboundRejectedRow { BizKey = bizKey, Reason = "op=delete not supported" });
+                return null;
+            }
+            mapped["is_deleted"] = 1;
+        }
+
+        var rawJson = JsonSerializer.Serialize(mapped, MdpInboundJson.Options);
+        return new MdpInboundPreparedRow
+        {
+            Dict = mapped,
+            RawJson = rawJson,
+            BizKey = bizKey,
+            SourceUpdatedAt = sourceUpdatedAt,
+            SourceVersion = sourceVersion
+        };
+    }
+
+    private async Task<(bool RejectStale, bool EqualTsOverride, MdpInboundPreparedRow Row)> GuardStaleAsync(
+        MdpEntity entity,
+        long tenantId,
+        string sourceTable,
+        MdpInboundPreparedRow incoming,
+        string inboundSource,
+        CancellationToken ct)
+    {
+        var existing = await LoadGuardRowAsync(entity.TargetTableName, tenantId, sourceTable, incoming.BizKey, inboundSource, ct);
+        if (existing == null)
+            return (false, false, incoming);
+
+        var oldHasVer = long.TryParse(existing.SourceVersion, out var oldVer);
+        var newHasVer = long.TryParse(incoming.SourceVersion, out var newVer);
+        if (oldHasVer && newHasVer)
+        {
+            if (newVer < oldVer)
+                return (true, false, incoming);
+            if (newVer > oldVer)
+                return (false, false, incoming);
+        }
+        else
+        {
+            var oldHasTs = DateTimeOffset.TryParse(existing.SourceUpdatedAt, out var oldTs);
+            if (!oldHasTs)
+                return (false, false, incoming);
+            if (!DateTimeOffset.TryParse(incoming.SourceUpdatedAt, out var newTs))
+                return (true, false, incoming);
+            if (newTs < oldTs)
+                return (true, false, incoming);
+            if (newTs > oldTs)
+                return (false, false, incoming);
+        }
+
+        var same = string.Equals(incoming.RawJson, existing.RawData, StringComparison.Ordinal);
+        return (false, !same, incoming);
+    }
+
+    private async Task<MdpInboundStgGuardRow> LoadGuardRowAsync(
+        string table, long tenantId, string sourceTable, string bizKey, string inboundSource, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(table) || !TableNameRe.IsMatch(table))
+            throw new InvalidOperationException("illegal target_table_name");
+
+        var rows = await _db.Ado.SqlQueryAsync<MdpInboundStgGuardRow>(
+            $"""
+            SELECT id AS Id,
+                   source_system AS SourceSystem,
+                   source_row_id AS SourceRowId,
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sourceUpdatedAt')) AS SourceUpdatedAt,
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.sourceVersion')) AS SourceVersion,
+                   raw_data AS RawData
+              FROM `{table}`
+             WHERE tenant_id=@t AND source_table=@st AND source_biz_key=@bk
+             ORDER BY CASE WHEN source_system=@sys THEN 0 ELSE 1 END
+             LIMIT 1
+            """,
+            new SugarParameter("@t", tenantId),
+            new SugarParameter("@st", sourceTable),
+            new SugarParameter("@bk", bizKey),
+            new SugarParameter("@sys", inboundSource));
+        return rows?.FirstOrDefault();
+    }
+
+    private async Task TransferIfNeededAsync(
+        MdpEntity entity, long tenantId, string sourceTable, string bizKey, string inboundSource, CancellationToken ct)
+    {
+        var existing = await LoadGuardRowAsync(entity.TargetTableName, tenantId, sourceTable, bizKey, inboundSource, ct);
+        if (existing == null)
+            return;
+        if (string.Equals(existing.SourceSystem, inboundSource, StringComparison.OrdinalIgnoreCase))
+            return;
+        if (string.IsNullOrWhiteSpace(entity.TargetTableName) || !TableNameRe.IsMatch(entity.TargetTableName))
+            throw new InvalidOperationException("illegal target_table_name");
+
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            UPDATE `{entity.TargetTableName}`
+               SET source_system=@sys, source_row_id=@bk
+             WHERE id=@id
+            """,
+            new SugarParameter("@sys", inboundSource),
+            new SugarParameter("@bk", bizKey),
+            new SugarParameter("@id", existing.Id));
+    }
+
+    private async Task MarkFailedAsync(long requestId, string error, long elapsedMs, CancellationToken ct)
+    {
+        try
+        {
+            await _db.Updateable<MdpInboundRequest>()
+                .SetColumns(r => new MdpInboundRequest
+                {
+                    Status = "FAILED",
+                    HttpStatus = 500,
+                    ResultCode = 500,
+                    ErrorMsg = error,
+                    ElapsedMs = (int)elapsedMs,
+                    UpdateTime = DateTime.Now
+                })
+                .Where(r => r.Id == requestId)
+                .ExecuteCommandAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "inbound mark FAILED failed requestId={Id}", requestId);
+        }
+    }
+
+    private static bool DeleteSupported(MdpEntity entity) =>
+        (entity.Remark ?? string.Empty).Contains("inbound_delete=supported", StringComparison.OrdinalIgnoreCase);
+
+    private static bool HasIsoOffset(string value) =>
+        !string.IsNullOrWhiteSpace(value)
+        && IsoOffsetRe.IsMatch(value)
+        && DateTimeOffset.TryParse(value, out _);
+
+    private static bool TryGetPropertyIgnoreCase(JsonElement obj, string name, out JsonElement value)
+    {
+        foreach (var prop in obj.EnumerateObject())
+        {
+            if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
+            {
+                value = prop.Value;
+                return true;
+            }
+        }
+        value = default;
+        return false;
+    }
+
+    private static string TryGetString(IDictionary<string, object?> row, string key)
+    {
+        foreach (var kv in row)
+        {
+            if (!string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
+                continue;
+            return kv.Value?.ToString();
+        }
+        return null;
+    }
+
+    private static bool TryGetLong(IDictionary<string, object?> row, string key, out long value)
+    {
+        value = 0;
+        var s = TryGetString(row, key);
+        return !string.IsNullOrWhiteSpace(s) && long.TryParse(s, out value);
+    }
+
+    private static bool IsSensitiveHeader(string name) =>
+        string.Equals(name, "X-Signature", StringComparison.OrdinalIgnoreCase)
+        || string.Equals(name, "Authorization", StringComparison.OrdinalIgnoreCase)
+        || string.Equals(name, "Cookie", StringComparison.OrdinalIgnoreCase);
+
+    private static bool IsDuplicateKey(Exception ex)
+    {
+        for (var e = ex; e != null; e = e.InnerException)
+        {
+            var m = e.Message ?? string.Empty;
+            if (m.Contains("Duplicate", StringComparison.OrdinalIgnoreCase)
+                || m.Contains("1062")
+                || m.Contains("uk_inbound_idem", StringComparison.OrdinalIgnoreCase))
+                return true;
+        }
+        return false;
+    }
+
+    private static string ToSha256Hex(byte[] raw) =>
+        Convert.ToHexString(SHA256.HashData(raw ?? [])).ToLowerInvariant();
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
+
+    private static object Envelope(int code, string message, object data) =>
+        new { code, message, data };
+
+    private static MdpInboundOutcome Fail(int status, string message) =>
+        new() { HttpStatus = status, Body = Envelope(status, message, null) };
+
+    private static MdpInboundOutcome Ok(object data) =>
+        new() { HttpStatus = 200, Body = Envelope(0, "ok", data) };
+
+    private async Task<string> PatchTransformEnqueuedAsync(long requestId, string receiptJson, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(receiptJson))
+            return receiptJson;
+        try
+        {
+            var node = JsonNode.Parse(receiptJson);
+            if (node is JsonObject obj && obj["data"] is JsonObject data)
+                data["transformEnqueued"] = true;
+            var updated = node.ToJsonString(MdpInboundJson.Options);
+            await _db.Updateable<MdpInboundRequest>()
+                .SetColumns(r => new MdpInboundRequest
+                {
+                    ReceiptJson = updated,
+                    UpdateTime = DateTime.Now
+                })
+                .Where(r => r.Id == requestId)
+                .ExecuteCommandAsync(ct);
+            return updated;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "inbound patch transformEnqueued failed requestId={Id}", requestId);
+            return receiptJson;
+        }
+    }
+
+    private static MdpInboundOutcome Receipt(string receiptJson, bool replay)
+    {
+        if (string.IsNullOrWhiteSpace(receiptJson))
+            return new MdpInboundOutcome { HttpStatus = 202, Body = Envelope(0, "ok", null) };
+        try
+        {
+            var node = JsonNode.Parse(receiptJson);
+            if (replay && node is JsonObject obj && obj["data"] is JsonObject data)
+                data["idempotentReplay"] = true;
+            return new MdpInboundOutcome { HttpStatus = 202, Body = node };
+        }
+        catch (JsonException)
+        {
+            return new MdpInboundOutcome { HttpStatus = 202, Body = Envelope(0, "ok", receiptJson) };
+        }
+    }
+}

+ 298 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundSnapshotService.cs

@@ -0,0 +1,298 @@
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+public sealed class MdpInboundSnapshotService : ITransient
+{
+    private static readonly Regex TableNameRe = new(@"^[A-Za-z0-9_]+$", RegexOptions.Compiled);
+    private const decimal DefaultDiffThreshold = 0.20m;
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpInboundAuthService _auth;
+    private readonly ILogger<MdpInboundSnapshotService> _logger;
+
+    public MdpInboundSnapshotService(
+        ISqlSugarClient db,
+        MdpInboundAuthService auth,
+        ILogger<MdpInboundSnapshotService> logger)
+    {
+        _db = db;
+        _auth = auth;
+        _logger = logger;
+    }
+
+    public async Task<MdpInboundOutcome> OpenAsync(
+        string entityCode, string accessKey, long tenantId, string clientIp, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
+        if (denied != null)
+            return Fail(denied.Value.Status, denied.Value.Message);
+
+        await ExpireOpenAsync(tenantId, accessKey, code, ct);
+
+        var existing = await _db.Queryable<MdpInboundSnapshot>()
+            .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN")
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .FirstAsync(ct);
+        if (existing != null)
+            return Fail(409, "snapshot already open");
+
+        var now = DateTime.Now;
+        var snapshotId = $"SNAP_{now:yyyyMMddHHmmss}_{RandomToken(8)}";
+        await _db.Insertable(new MdpInboundSnapshot
+        {
+            SnapshotId = snapshotId,
+            TenantId = tenantId,
+            AccessKey = accessKey,
+            EntityCode = code,
+            Status = "OPEN",
+            LastSeq = 0,
+            RowCount = 0,
+            OpenedAt = now,
+            ExpireAt = now.AddHours(24),
+            CreateTime = now,
+            UpdateTime = now
+        }).ExecuteCommandAsync(ct);
+
+        return Ok(new { snapshotId, expireAt = now.AddHours(24), status = "OPEN" }, 201);
+    }
+
+    public async Task EnsureAcceptAsync(
+        string snapshotId, string accessKey, long tenantId, string entityCode, int? seq, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(snapshotId))
+            return;
+        if (seq is null or < 1)
+            throw new MdpInboundBatchException(400, "seq required");
+
+        var snap = await LoadAsync(snapshotId, accessKey, tenantId, entityCode, ct)
+                   ?? throw new MdpInboundBatchException(400, "snapshot not found");
+
+        if (snap.ExpireAt < DateTime.Now)
+        {
+            await MarkStatusAsync(snap.Id, "EXPIRED", ct);
+            throw new MdpInboundBatchException(400, "snapshot expired");
+        }
+        if (!string.Equals(snap.Status, "OPEN", StringComparison.OrdinalIgnoreCase))
+            throw new MdpInboundBatchException(409, "snapshot not open");
+        if (seq.Value != snap.LastSeq + 1)
+        {
+            _logger.LogWarning("inbound snapshot seq gap snapshot={Snap} expected={Exp} got={Got}",
+                snapshotId, snap.LastSeq + 1, seq);
+            throw new MdpInboundBatchException(400, "seq gap");
+        }
+    }
+
+    public async Task AdvanceAsync(string snapshotId, int seq, int rowCount, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(snapshotId))
+            return;
+        var n = await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_inbound_snapshot
+               SET last_seq=@seq, row_count=row_count+@rows, update_time=@now
+             WHERE snapshot_id=@id AND status='OPEN' AND last_seq=@prev
+            """,
+            new SugarParameter("@seq", seq),
+            new SugarParameter("@rows", rowCount),
+            new SugarParameter("@now", DateTime.Now),
+            new SugarParameter("@id", snapshotId),
+            new SugarParameter("@prev", seq - 1));
+        if (n != 1)
+            throw new InvalidOperationException("snapshot seq advance failed");
+    }
+
+    public async Task<MdpInboundOutcome> CommitAsync(
+        string entityCode, string snapshotId, string accessKey, long tenantId, string clientIp,
+        bool force, CancellationToken ct)
+    {
+        var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
+        var denied = await _auth.CheckAsync(accessKey, tenantId, code, clientIp, ct);
+        if (denied != null)
+            return Fail(denied.Value.Status, denied.Value.Message);
+
+        var grant = await _auth.GetGrantAsync(accessKey, code, ct);
+        if (grant == null)
+            return Fail(403, "entity not authorized");
+        var entity = await _db.Queryable<MdpEntity>()
+            .Where(e => e.InboundEnabled == 1)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .FirstAsync(ct);
+        if (entity == null)
+            return Fail(403, "entity not authorized");
+
+        var snap = await LoadAsync(snapshotId, accessKey, tenantId, code, ct)
+                   ?? throw new MdpInboundBatchException(400, "snapshot not found");
+
+        if (snap.ExpireAt < DateTime.Now)
+        {
+            await MarkStatusAsync(snap.Id, "EXPIRED", ct);
+            return Fail(400, "snapshot expired");
+        }
+
+        if (force)
+        {
+            if (!string.Equals(snap.Status, "DIFF_BLOCKED", StringComparison.OrdinalIgnoreCase))
+                return Fail(409, "force only when DIFF_BLOCKED");
+        }
+        else if (!string.Equals(snap.Status, "OPEN", StringComparison.OrdinalIgnoreCase))
+        {
+            return Fail(409, "snapshot not open");
+        }
+
+        if (string.IsNullOrWhiteSpace(entity.TargetTableName) || !TableNameRe.IsMatch(entity.TargetTableName))
+            return Fail(500, "illegal target_table_name");
+
+        var sourceTable = string.IsNullOrWhiteSpace(entity.SourceTableName) ? entity.EntityCode : entity.SourceTableName;
+        var batchIds = await _db.Queryable<MdpInboundRequest>()
+            .Where(r => r.SnapshotId == snapshotId && r.Status == "COMMITTED")
+            .Select(r => r.SyncBatchId)
+            .ToListAsync(ct);
+
+        var snapshotKeys = new HashSet<string>(StringComparer.Ordinal);
+        if (batchIds.Count > 0)
+        {
+            var keys = await _db.Ado.SqlQueryAsync<string>(
+                $"""
+                SELECT source_biz_key FROM `{entity.TargetTableName}`
+                 WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
+                   AND sync_batch_id IN ({string.Join(",", batchIds.Select((_, i) => "@b" + i))})
+                """,
+                new[]
+                {
+                    new SugarParameter("@t", tenantId),
+                    new SugarParameter("@sys", grant.SourceCode),
+                    new SugarParameter("@st", sourceTable)
+                }.Concat(batchIds.Select((b, i) => new SugarParameter("@b" + i, b))).ToArray());
+            foreach (var k in keys.Where(x => !string.IsNullOrWhiteSpace(x)))
+                snapshotKeys.Add(k);
+        }
+
+        var allKeys = await _db.Ado.SqlQueryAsync<string>(
+            $"""
+            SELECT source_biz_key FROM `{entity.TargetTableName}`
+             WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
+            """,
+            new SugarParameter("@t", tenantId),
+            new SugarParameter("@sys", grant.SourceCode),
+            new SugarParameter("@st", sourceTable));
+        var missing = allKeys.Where(k => !string.IsNullOrWhiteSpace(k) && !snapshotKeys.Contains(k)).Distinct().ToList();
+        var total = Math.Max(allKeys.Count, 1);
+        var ratio = (decimal)missing.Count / total;
+
+        if (!force && ratio > DefaultDiffThreshold)
+        {
+            await _db.Updateable<MdpInboundSnapshot>()
+                .SetColumns(s => new MdpInboundSnapshot
+                {
+                    Status = "DIFF_BLOCKED",
+                    DiffMissing = missing.Count,
+                    DiffRatio = ratio,
+                    UpdateTime = DateTime.Now
+                })
+                .Where(s => s.Id == snap.Id)
+                .ExecuteCommandAsync(ct);
+            _logger.LogWarning("inbound snapshot DIFF_BLOCKED snap={Snap} missing={N} ratio={R}",
+                snapshotId, missing.Count, ratio);
+            return Fail(409, "diff threshold exceeded");
+        }
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            if (missing.Count > 0)
+            {
+                var inParams = missing.Select((k, i) => new SugarParameter("@k" + i, k)).ToArray();
+                var emptyObj = "{}";
+                await _db.Ado.ExecuteCommandAsync(
+                    $"""
+                    UPDATE `{entity.TargetTableName}`
+                       SET raw_data = JSON_SET(IFNULL(raw_data, '{emptyObj}'), '$.is_deleted', 1),
+                           process_status='PENDING',
+                           update_time=@now
+                     WHERE tenant_id=@t AND source_system=@sys AND source_table=@st
+                       AND source_biz_key IN ({string.Join(",", missing.Select((_, i) => "@k" + i))})
+                    """,
+                    new[]
+                    {
+                        new SugarParameter("@now", DateTime.Now),
+                        new SugarParameter("@t", tenantId),
+                        new SugarParameter("@sys", grant.SourceCode),
+                        new SugarParameter("@st", sourceTable)
+                    }.Concat(inParams).ToArray());
+            }
+
+            await _db.Updateable<MdpInboundSnapshot>()
+                .SetColumns(s => new MdpInboundSnapshot
+                {
+                    Status = "COMMITTED",
+                    DiffMissing = missing.Count,
+                    DiffRatio = ratio,
+                    CommittedAt = DateTime.Now,
+                    UpdateTime = DateTime.Now
+                })
+                .Where(s => s.Id == snap.Id)
+                .ExecuteCommandAsync(ct);
+        });
+
+        if (!tran.IsSuccess)
+        {
+            _logger.LogError(tran.ErrorException, "inbound snapshot commit failed snap={Snap}", snapshotId);
+            return Fail(500, "snapshot commit failed");
+        }
+
+        return Ok(new
+        {
+            snapshotId,
+            status = "COMMITTED",
+            diffMissing = missing.Count,
+            diffRatio = ratio,
+            force
+        });
+    }
+
+    public async Task ExpireStaleOpenAsync(CancellationToken ct)
+    {
+        await _db.Updateable<MdpInboundSnapshot>()
+            .SetColumns(s => new MdpInboundSnapshot { Status = "EXPIRED", UpdateTime = DateTime.Now })
+            .Where(s => s.Status == "OPEN" && s.ExpireAt < DateTime.Now)
+            .ExecuteCommandAsync(ct);
+    }
+
+    private async Task ExpireOpenAsync(long tenantId, string accessKey, string entityCode, CancellationToken ct)
+    {
+        await _db.Updateable<MdpInboundSnapshot>()
+            .SetColumns(s => new MdpInboundSnapshot { Status = "EXPIRED", UpdateTime = DateTime.Now })
+            .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN" && s.ExpireAt < DateTime.Now)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entityCode))
+            .ExecuteCommandAsync(ct);
+    }
+
+    private Task<MdpInboundSnapshot> LoadAsync(
+        string snapshotId, string accessKey, long tenantId, string entityCode, CancellationToken ct) =>
+        _db.Queryable<MdpInboundSnapshot>()
+            .Where(s => s.SnapshotId == snapshotId && s.AccessKey == accessKey && s.TenantId == tenantId)
+            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", (entityCode ?? "").ToUpperInvariant()))
+            .FirstAsync(ct);
+
+    private Task<int> MarkStatusAsync(long id, string status, CancellationToken ct) =>
+        _db.Updateable<MdpInboundSnapshot>()
+            .SetColumns(s => new MdpInboundSnapshot { Status = status, UpdateTime = DateTime.Now })
+            .Where(s => s.Id == id)
+            .ExecuteCommandAsync(ct);
+
+    private static string RandomToken(int len)
+    {
+        var bytes = RandomNumberGenerator.GetBytes(len);
+        return Convert.ToHexString(bytes)[..len];
+    }
+
+    private static MdpInboundOutcome Fail(int status, string message) =>
+        new() { HttpStatus = status, Body = new { code = status, message, data = (object)null } };
+
+    private static MdpInboundOutcome Ok(object data, int http = 200) =>
+        new() { HttpStatus = http, Body = new { code = 0, message = "ok", data } };
+}

+ 16 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpEntity.cs

@@ -72,6 +72,22 @@ public class MdpEntity
     [SugarColumn(ColumnName = "is_public_template", DefaultValue = "0")]
     public int IsPublicTemplate { get; set; }
 
+    /// <summary>入站开通开关。不走 CodeFirst 自动加列,以 UpdateScripts 为准。</summary>
+    [SugarColumn(ColumnName = "inbound_enabled")]
+    public int InboundEnabled { get; set; }
+
+    [SugarColumn(ColumnName = "inbound_max_rows")]
+    public int InboundMaxRows { get; set; } = 500;
+
+    [SugarColumn(ColumnName = "stale_guard_enabled")]
+    public int StaleGuardEnabled { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "silence_alert_hours", IsNullable = true)]
+    public int? SilenceAlertHours { get; set; }
+
+    [SugarColumn(ColumnName = "silence_calendar", Length = 20, IsNullable = true)]
+    public string? SilenceCalendar { get; set; }
+
     [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
     public string? Remark { get; set; }
 

+ 274 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/DataPlatform/MdpInboundEntities.cs

@@ -0,0 +1,274 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+/// <summary>第三方推数入站授权。不走 CodeFirst,以 UpdateScripts 为唯一建表口径。</summary>
+[SugarTable("mdp_inbound_grant", "第三方推数入站授权")]
+public class MdpInboundGrant
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "access_key", Length = 64)]
+    public string AccessKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "factory_id", IsNullable = true)]
+    public long? FactoryId { get; set; }
+
+    [SugarColumn(ColumnName = "source_code", Length = 50)]
+    public string SourceCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "rate_limit_per_min")]
+    public int RateLimitPerMin { get; set; } = 60;
+
+    [SugarColumn(ColumnName = "batch_row_limit")]
+    public int BatchRowLimit { get; set; } = 500;
+
+    [SugarColumn(ColumnName = "ip_allowlist", Length = 1000, IsNullable = true)]
+    public string IpAllowlist { get; set; }
+
+    [SugarColumn(ColumnName = "status")]
+    public int Status { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "remark", Length = 500, IsNullable = true)]
+    public string Remark { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}
+
+/// <summary>入站请求幂等状态机 + 审计。不走 CodeFirst。</summary>
+[SugarTable("mdp_inbound_request", "第三方推数入站请求")]
+public class MdpInboundRequest
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "access_key", Length = 64)]
+    public string AccessKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "idempotency_key", Length = 128)]
+    public string IdempotencyKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "request_hash", Length = 64)]
+    public string RequestHash { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "sync_batch_id", Length = 64)]
+    public string SyncBatchId { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "snapshot_id", Length = 64, IsNullable = true)]
+    public string SnapshotId { get; set; }
+
+    [SugarColumn(ColumnName = "status", Length = 16)]
+    public string Status { get; set; } = "RECEIVED";
+
+    [SugarColumn(ColumnName = "http_status")]
+    public int HttpStatus { get; set; }
+
+    [SugarColumn(ColumnName = "result_code")]
+    public int ResultCode { get; set; }
+
+    [SugarColumn(ColumnName = "accepted")]
+    public int Accepted { get; set; }
+
+    [SugarColumn(ColumnName = "rejected")]
+    public int Rejected { get; set; }
+
+    [SugarColumn(ColumnName = "stale_rejected")]
+    public int StaleRejected { get; set; }
+
+    [SugarColumn(ColumnName = "equal_ts_override")]
+    public int EqualTsOverride { get; set; }
+
+    [SugarColumn(ColumnName = "receipt_json", ColumnDataType = "mediumtext", IsNullable = true)]
+    public string ReceiptJson { get; set; }
+
+    [SugarColumn(ColumnName = "error_msg", Length = 1000, IsNullable = true)]
+    public string ErrorMsg { get; set; }
+
+    [SugarColumn(ColumnName = "client_ip", Length = 64, IsNullable = true)]
+    public string ClientIp { get; set; }
+
+    [SugarColumn(ColumnName = "elapsed_ms")]
+    public int ElapsedMs { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}
+
+/// <summary>入站原始信封归档。不走 CodeFirst。</summary>
+[SugarTable("mdp_inbound_envelope", "入站原始信封归档")]
+public class MdpInboundEnvelope
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "request_id")]
+    public long RequestId { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "access_key", Length = 64)]
+    public string AccessKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "path", Length = 300)]
+    public string Path { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "headers_json", ColumnDataType = "text", IsNullable = true)]
+    public string HeadersJson { get; set; }
+
+    [SugarColumn(ColumnName = "body", ColumnDataType = "mediumtext")]
+    public string Body { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "body_sha256", Length = 64)]
+    public string BodySha256 { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+}
+
+/// <summary>入站全量快照批次。不走 CodeFirst。</summary>
+[SugarTable("mdp_inbound_snapshot", "入站全量快照批次")]
+public class MdpInboundSnapshot
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "snapshot_id", Length = 64)]
+    public string SnapshotId { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "access_key", Length = 64)]
+    public string AccessKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "status", Length = 16)]
+    public string Status { get; set; } = "OPEN";
+
+    [SugarColumn(ColumnName = "last_seq")]
+    public int LastSeq { get; set; }
+
+    [SugarColumn(ColumnName = "row_count")]
+    public int RowCount { get; set; }
+
+    [SugarColumn(ColumnName = "diff_missing", IsNullable = true)]
+    public int? DiffMissing { get; set; }
+
+    [SugarColumn(ColumnName = "diff_ratio", DecimalDigits = 4, IsNullable = true)]
+    public decimal? DiffRatio { get; set; }
+
+    [SugarColumn(ColumnName = "opened_at")]
+    public DateTime OpenedAt { get; set; }
+
+    [SugarColumn(ColumnName = "expire_at")]
+    public DateTime ExpireAt { get; set; }
+
+    [SugarColumn(ColumnName = "committed_at", IsNullable = true)]
+    public DateTime? CommittedAt { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}
+
+/// <summary>主数据镜像键冲突待裁决。不走 CodeFirst。</summary>
+[SugarTable("mdp_inbound_conflict", "主数据镜像键冲突待裁决")]
+public class MdpInboundConflict
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "biz_key", Length = 300)]
+    public string BizKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "source_system", Length = 50)]
+    public string SourceSystem { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "incoming_raw", ColumnDataType = "mediumtext")]
+    public string IncomingRaw { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "mirror_table", Length = 64)]
+    public string MirrorTable { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "conflict_type", Length = 40)]
+    public string ConflictType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "status", Length = 16)]
+    public string Status { get; set; } = "PENDING";
+
+    [SugarColumn(ColumnName = "resolution", Length = 500, IsNullable = true)]
+    public string Resolution { get; set; }
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}
+
+/// <summary>入站对外契约字段两层映射。与 PULL 的 mdp_field_mapping 不是同一张表。</summary>
+[SugarTable("mdp_field_map", "入站对外契约字段两层映射")]
+public class MdpFieldMap
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "entity_code", Length = 100)]
+    public string EntityCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "access_key", Length = 64)]
+    public string AccessKey { get; set; } = "*";
+
+    [SugarColumn(ColumnName = "contract_field", Length = 200)]
+    public string ContractField { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "raw_key", Length = 200)]
+    public string RawKey { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "is_required")]
+    public int IsRequired { get; set; }
+
+    [SugarColumn(ColumnName = "status")]
+    public int Status { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "create_time")]
+    public DateTime CreateTime { get; set; }
+
+    [SugarColumn(ColumnName = "update_time")]
+    public DateTime UpdateTime { get; set; }
+}

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Sales/AdoS0CustMaster.cs

@@ -93,4 +93,10 @@ public class AdoS0CustMaster : ITenantIdFilter
 
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "source_system", Length = 50, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "source_updated_at", IsNullable = true)]
+    public DateTime? SourceUpdatedAt { get; set; }
 }

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Sales/AdoS0ItemMaster.cs

@@ -199,4 +199,10 @@ public class AdoS0ItemMaster : ITenantIdFilter
 
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "source_system", Length = 50, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "source_updated_at", IsNullable = true)]
+    public DateTime? SourceUpdatedAt { get; set; }
 }

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Supply/AdoS0SuppMaster.cs

@@ -111,5 +111,11 @@ public class AdoS0SuppMaster : ITenantIdFilter
 
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "source_system", Length = 50, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "source_updated_at", IsNullable = true)]
+    public DateTime? SourceUpdatedAt { get; set; }
 }
 

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0EmployeeMaster.cs

@@ -101,4 +101,10 @@ public class AdoS0EmployeeMaster : ITenantIdFilter
 
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "source_system", Length = 50, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "source_updated_at", IsNullable = true)]
+    public DateTime? SourceUpdatedAt { get; set; }
 }

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0LocationMaster.cs

@@ -51,4 +51,10 @@ public class AdoS0LocationMaster : ITenantIdFilter
 
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "source_system", Length = 50, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnName = "source_updated_at", IsNullable = true)]
+    public DateTime? SourceUpdatedAt { get; set; }
 }

+ 127 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Furion.Schedule;
 using Microsoft.Extensions.DependencyInjection;
@@ -56,6 +57,12 @@ public class MdpSourceHealthCheckJob : IJob
                     src.HealthMsg = $"HTTP {(int)resp.StatusCode}";
                     if (src.HealthStatus == 1) ok++; else fail++;
                 }
+                else if (string.Equals(src.SourceType, "API_INBOUND", StringComparison.OrdinalIgnoreCase))
+                {
+                    src.HealthStatus = 1;
+                    src.HealthMsg = "OK";
+                    ok++;
+                }
                 else
                 {
                     src.HealthStatus = 0;
@@ -79,5 +86,125 @@ public class MdpSourceHealthCheckJob : IJob
 
         if (sources.Count > 0)
             _logger.LogInformation("[MdpSourceHealthCheckJob] total={Total} ok={Ok} fail={Fail}", sources.Count, ok, fail);
+
+        await ExpireInboundSnapshotsAsync(scope, stoppingToken);
+        await CheckInboundSilenceAsync(db, sources, stoppingToken);
+    }
+
+    /// <summary>顺带把 OPEN 且已过期的快照置 EXPIRED;未 commit 不产生删除语义。</summary>
+    private async Task ExpireInboundSnapshotsAsync(IServiceScope scope, CancellationToken ct)
+    {
+        try
+        {
+            var snaps = scope.ServiceProvider.GetRequiredService<MdpInboundSnapshotService>();
+            await snaps.ExpireStaleOpenAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[MdpSourceHealthCheckJob] inbound snapshot expire failed");
+        }
     }
+
+    /// <summary>
+    /// 入站实体沉默:inbound_enabled=1 且配置了 silence_alert_hours,
+    /// 最近 COMMITTED 超过阈值且当天命中 silence_calendar(空=每天)则写回源健康告警。
+    /// 从未推送过的实体以 update_time/create_time 为基线。
+    /// </summary>
+    private async Task CheckInboundSilenceAsync(
+        ISqlSugarClient db, List<MdpSource> sources, CancellationToken ct)
+    {
+        List<MdpEntity> entities;
+        try
+        {
+            entities = await db.Queryable<MdpEntity>()
+                .Where(e => e.InboundEnabled == 1 && e.SilenceAlertHours != null)
+                .ToListAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[MdpSourceHealthCheckJob] inbound silence query entities failed");
+            return;
+        }
+
+        if (entities.Count == 0)
+            return;
+
+        var today = DateTime.Now;
+        var isoDow = today.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)today.DayOfWeek;
+        var alertsBySource = new Dictionary<long, List<string>>();
+
+        foreach (var entity in entities)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (!HitsSilenceCalendar(entity.SilenceCalendar, isoDow))
+                continue;
+
+            DateTime? lastCommitted = null;
+            try
+            {
+                var last = await db.Queryable<MdpInboundRequest>()
+                    .Where(r => r.Status == "COMMITTED")
+                    .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entity.EntityCode.ToUpperInvariant()))
+                    .OrderBy(r => r.CreateTime, OrderByType.Desc)
+                    .FirstAsync(ct);
+                lastCommitted = last?.CreateTime;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "[MdpSourceHealthCheckJob] inbound silence query requests failed entity={Entity}", entity.EntityCode);
+                continue;
+            }
+
+            var baseline = lastCommitted
+                           ?? (entity.UpdateTime != default ? entity.UpdateTime : entity.CreateTime);
+            var hours = entity.SilenceAlertHours.GetValueOrDefault();
+            if (hours <= 0)
+                continue;
+            if ((today - baseline).TotalHours <= hours)
+                continue;
+
+            var lastText = lastCommitted.HasValue
+                ? lastCommitted.Value.ToString("yyyy-MM-dd HH:mm:ss")
+                : "never";
+            var msg = $"INBOUND silence: {entity.EntityCode} last COMMITTED {lastText} (threshold {hours}h)";
+            _logger.LogWarning("[MdpSourceHealthCheckJob] {Message}", msg);
+            if (!alertsBySource.TryGetValue(entity.SourceId, out var list))
+            {
+                list = [];
+                alertsBySource[entity.SourceId] = list;
+            }
+            list.Add(msg);
+        }
+
+        var sourceById = sources.ToDictionary(s => s.Id);
+        foreach (var (sourceId, messages) in alertsBySource)
+        {
+            if (!sourceById.TryGetValue(sourceId, out var src))
+            {
+                src = await db.Queryable<MdpSource>().Where(s => s.Id == sourceId).FirstAsync(ct);
+                if (src == null)
+                    continue;
+            }
+
+            var now = DateTime.Now;
+            src.HealthStatus = 0;
+            src.HealthMsg = Truncate(string.Join("; ", messages), 480);
+            src.LastHealthCheck = now;
+            src.UpdateTime = now;
+            await db.Updateable(src)
+                .UpdateColumns(x => new { x.HealthStatus, x.HealthMsg, x.LastHealthCheck, x.UpdateTime })
+                .ExecuteCommandAsync(ct);
+        }
+    }
+
+    private static bool HitsSilenceCalendar(string calendar, int isoDow)
+    {
+        if (string.IsNullOrWhiteSpace(calendar))
+            return true;
+        var days = calendar.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        return days.Any(d => int.TryParse(d, out var n) && n == isoDow);
+    }
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max];
 }

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

@@ -10,6 +10,8 @@ using Admin.NET.Plugin.AiDOP.Entity.S8.OrderFlow;
 using Admin.NET.Plugin.AiDOP.Order;
 using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
 using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+using Microsoft.AspNetCore.Authentication;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
@@ -113,6 +115,11 @@ public class Startup : AppStartup
 
         // S8-UAT-APPROVAL-REPAIR-1:S8 审批流审批人配置一次性对账(只修解析为 0 人的节点)。
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8ApprovalFlowRepairService>();
+
+        // API_INBOUND:插件侧独立签名 scheme(严禁传入默认 scheme 参数,全局默认仍是 JWT)
+        services.AddAuthentication()
+            .AddScheme<InboundSignatureOptions, InboundSignatureHandler>(
+                InboundSignatureDefaults.AuthenticationScheme, _ => { });
     }
 
     /// <summary>

+ 262 - 0
tools/mock/inbound/push_demo.py

@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""API_INBOUND 联调用例。签名按方案 §5.2:METHOD&path&key&ts&nonce&sha256&idem。
+
+密钥只从 --secret 或环境变量 AIDOP_INBOUND_SECRET 读取,不写死生产 secret。
+联调种子 TESTKPARTNER 的公开占位口令可用 --use-seed-secret(仅开发库)。
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import hmac
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+import uuid
+from base64 import b64encode
+from datetime import datetime, timezone, timedelta
+
+# 公开联调种子,不是生产密钥
+_SEED_SECRET = "TESTKPARTNER_SECRET_DO_NOT_USE_IN_PROD"
+
+
+def sha256_hex(data: bytes) -> str:
+    return hashlib.sha256(data).hexdigest()
+
+
+def sign(secret: str, message: str) -> str:
+    digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
+    return b64encode(digest).decode("ascii")
+
+
+def now_iso() -> str:
+    return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%dT%H:%M:%S+08:00")
+
+
+def sales_row(**extra) -> dict:
+    row = {
+        "bill_no": extra.pop("bill_no", "SO-INB-DEMO-001"),
+        "entry_seq": extra.pop("entry_seq", 1),
+        "seorder_id": extra.pop("seorder_id", 900001),
+        "qty": extra.pop("qty", 10),
+        "item_number": "ITEM-INB-001",
+        "sourceUpdatedAt": extra.pop("sourceUpdatedAt", now_iso()),
+    }
+    row.update(extra)
+    return row
+
+
+def envelope(rows: list, snapshot_id: str | None = None, seq: int | None = None) -> dict:
+    data: dict = {"list": rows}
+    if snapshot_id:
+        data["snapshotId"] = snapshot_id
+    if seq is not None:
+        data["seq"] = seq
+    return {"data": data}
+
+
+class InboundClient:
+    def __init__(self, base_url: str, access_key: str, secret: str):
+        self.base = base_url.rstrip("/")
+        self.key = access_key
+        self.secret = secret
+
+    def request(
+        self,
+        method: str,
+        path: str,
+        body: bytes | None = None,
+        *,
+        idem: str | None = None,
+        ts: int | None = None,
+        nonce: str | None = None,
+        skip_idem: bool = False,
+        tamper_body: bool = False,
+        extra_headers: dict | None = None,
+    ) -> tuple[int, dict | str]:
+        raw = body if body is not None else b""
+        digest = sha256_hex(raw)
+        if tamper_body:
+            raw = raw + b"x"
+        ts_s = str(ts if ts is not None else int(time.time()))
+        nonce = nonce or uuid.uuid4().hex
+        idem = idem or f"IDEM-{uuid.uuid4().hex}"
+        message = f"{method.upper()}&{path}&{self.key}&{ts_s}&{nonce}&{digest}&{idem}"
+        headers = {
+            "Content-Type": "application/json",
+            "X-Access-Key": self.key,
+            "X-Timestamp": ts_s,
+            "X-Nonce": nonce,
+            "X-Content-SHA256": digest,
+            "X-Signature": sign(self.secret, message),
+        }
+        if not skip_idem:
+            headers["Idempotency-Key"] = idem
+        if extra_headers:
+            headers.update(extra_headers)
+        req = urllib.request.Request(self.base + path, data=raw if method != "GET" else None, method=method, headers=headers)
+        try:
+            with urllib.request.urlopen(req, timeout=30) as resp:
+                text = resp.read().decode("utf-8")
+                return resp.status, _parse(text)
+        except urllib.error.HTTPError as ex:
+            text = ex.read().decode("utf-8")
+            return ex.code, _parse(text)
+        except urllib.error.URLError as ex:
+            return 0, {"message": str(ex.reason)}
+
+
+def _parse(text: str):
+    try:
+        return json.loads(text) if text else {}
+    except json.JSONDecodeError:
+        return text
+
+
+def report(name: str, ok: bool, detail) -> bool:
+    mark = "PASS" if ok else "FAIL"
+    print(f"[{mark}] {name}: {detail}")
+    return ok
+
+
+def case_happy(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
+    status, data = c.request("POST", path, body)
+    return report("happy", status == 202 and isinstance(data, dict) and data.get("code") in (0, 2), (status, data))
+
+
+def case_missing_idem(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
+    status, data = c.request("POST", path, body, skip_idem=True)
+    msg = data.get("message") if isinstance(data, dict) else data
+    return report("missing Idempotency-Key", status == 400 and "Idempotency-Key" in str(msg), (status, data))
+
+
+def case_tamper_body(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
+    status, data = c.request("POST", path, body, tamper_body=True)
+    msg = data.get("message") if isinstance(data, dict) else data
+    return report("body+1 byte", status == 400 and "digest" in str(msg).lower(), (status, data))
+
+
+def case_expired_ts(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row()]), ensure_ascii=False).encode("utf-8")
+    status, data = c.request("POST", path, body, ts=int(time.time()) - 3600)
+    return report("expired timestamp", status == 401, (status, data))
+
+
+def case_nonce_replay(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row(bill_no="SO-NONCE-1")]), ensure_ascii=False).encode("utf-8")
+    nonce = uuid.uuid4().hex
+    status1, _ = c.request("POST", path, body, nonce=nonce)
+    status2, data2 = c.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
+    return report("nonce replay", status1 == 202 and status2 == 401, (status1, status2, data2))
+
+
+def case_same_key_replay(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    body = json.dumps(envelope([sales_row(bill_no="SO-REPLAY-1")]), ensure_ascii=False).encode("utf-8")
+    idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
+    status1, data1 = c.request("POST", path, body, idem=idem)
+    status2, data2 = c.request("POST", path, body, idem=idem)
+    replay = isinstance(data2, dict) and (data2.get("data") or {}).get("idempotentReplay") is True
+    return report("same-key replay", status1 == 202 and status2 == 202 and replay, (status1, status2, data2))
+
+
+def case_same_key_diff_body(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
+    b1 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-1")]), ensure_ascii=False).encode("utf-8")
+    b2 = json.dumps(envelope([sales_row(bill_no="SO-DIFF-2")]), ensure_ascii=False).encode("utf-8")
+    status1, _ = c.request("POST", path, b1, idem=idem)
+    status2, data2 = c.request("POST", path, b2, idem=idem)
+    return report("same-key different body", status1 == 202 and status2 == 409, (status1, status2, data2))
+
+
+def case_partial(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    rows = [sales_row(bill_no="SO-PART-OK"), {"qty": 1, "sourceUpdatedAt": now_iso()}]
+    body = json.dumps(envelope(rows), ensure_ascii=False).encode("utf-8")
+    status, data = c.request("POST", path, body)
+    code = data.get("code") if isinstance(data, dict) else None
+    return report("partial", status == 202 and code == 2, (status, data))
+
+
+def case_stale(c: InboundClient, entity: str) -> bool:
+    path = f"/api/mdp/inbound/{entity}"
+    newer = "2026-09-11T12:00:00+08:00"
+    older = "2026-09-01T12:00:00+08:00"
+    b1 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=newer)]), ensure_ascii=False).encode("utf-8")
+    b2 = json.dumps(envelope([sales_row(bill_no="SO-STALE-1", sourceUpdatedAt=older)]), ensure_ascii=False).encode("utf-8")
+    status1, _ = c.request("POST", path, b1)
+    status2, data2 = c.request("POST", path, b2)
+    rejected = ((data2.get("data") or {}).get("staleRejected") if isinstance(data2, dict) else 0) or 0
+    return report("stale", status1 == 202 and status2 == 202 and rejected >= 1, (status1, status2, data2))
+
+
+def case_snapshot(c: InboundClient, entity: str) -> bool:
+    open_path = f"/api/mdp/inbound/{entity}/snapshots"
+    status, data = c.request("POST", open_path, b"")
+    snap = (data.get("data") or {}).get("snapshotId") if isinstance(data, dict) else None
+    if status not in (200, 201) or not snap:
+        return report("snapshot open", False, (status, data))
+    push = f"/api/mdp/inbound/{entity}"
+    b1 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-1")], snap, 1), ensure_ascii=False).encode("utf-8")
+    b2 = json.dumps(envelope([sales_row(bill_no="SO-SNAP-2")], snap, 2), ensure_ascii=False).encode("utf-8")
+    s1, _ = c.request("POST", push, b1)
+    s2, _ = c.request("POST", push, b2)
+    commit_path = f"/api/mdp/inbound/{entity}/snapshots/{snap}/commit"
+    sc, dc = c.request("POST", commit_path, b"")
+    committed = isinstance(dc, dict) and ((dc.get("data") or {}).get("status") == "COMMITTED" or dc.get("code") == 0)
+    return report("snapshot open→2 batches→commit", s1 == 202 and s2 == 202 and sc in (200, 202) and committed, (s1, s2, sc, dc))
+
+
+CASES = {
+    "happy": case_happy,
+    "missing-idem": case_missing_idem,
+    "tamper-body": case_tamper_body,
+    "expired-ts": case_expired_ts,
+    "nonce-replay": case_nonce_replay,
+    "same-key-replay": case_same_key_replay,
+    "same-key-diff-body": case_same_key_diff_body,
+    "partial": case_partial,
+    "stale": case_stale,
+    "snapshot": case_snapshot,
+}
+
+
+def main() -> int:
+    p = argparse.ArgumentParser(description="API_INBOUND mock push")
+    p.add_argument("--base-url", default="http://127.0.0.1:5005")
+    p.add_argument("--access-key", default="TESTKPARTNER")
+    p.add_argument("--secret", default=os.environ.get("AIDOP_INBOUND_SECRET", ""))
+    p.add_argument("--use-seed-secret", action="store_true", help="使用公开联调种子口令(非生产)")
+    p.add_argument("--entity", default="S1_SALES_ORDER_ENTRY")
+    p.add_argument("--case", default="all", choices=["all", *CASES])
+    args = p.parse_args()
+
+    secret = args.secret
+    if args.use_seed_secret:
+        secret = _SEED_SECRET
+    if not secret:
+        print("missing --secret or AIDOP_INBOUND_SECRET(开发库可加 --use-seed-secret)", file=sys.stderr)
+        return 2
+
+    client = InboundClient(args.base_url, args.access_key, secret)
+    names = list(CASES) if args.case == "all" else [args.case]
+    ok = True
+    for name in names:
+        ok = CASES[name](client, args.entity) and ok
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())