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

docs(s5-s7): 新增独立SQLServer跨库回写方案与任务书,补UAT对接指南

- 新增 S5-S7 与独立 SQLServer MES+WMS 双向对接方案与可执行任务书
  (入站沿用/新建 stg-std,回写新增 MdpDbPushExecutor + Job 分发,含
   payload 约定、幂等/防注入、DDL/DML 权限、手动联调、out-of-scope)
- 一并纳入此前 S5-S7 双模式对接 UAT 配置操作指南

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 2 долоо хоног өмнө
parent
commit
c6b301b71d

+ 664 - 0
doc/plan/AIDOP双模式S5-S7对接UAT配置操作指南.md

@@ -0,0 +1,664 @@
+# AIDOP 双模式对接 UAT 配置操作指南(S5–S7 重点 · T8 / MES+WMS)
+
+| 项 | 内容 |
+|----|------|
+| 读者 | UAT 测试人员、实施配置人员 |
+| 范围 | **S5 物料仓 / S6 制造 / S7 成品仓** 与第三方 **T8(DB)**、**MES+WMS(API)** 双模式对接 |
+| 版本基线 | Web ≥ `2.4.258` · server ≥ `1.0.264` · UpdateScripts `1.0.259`–`1.0.264` |
+| 上游 | [`AIDOP双模式全模块对接交付级任务书.md`](./AIDOP双模式全模块对接交付级任务书.md) |
+| 编写日期 | 2026-07-24 |
+
+---
+
+## 0. 先读懂:两条测试路 + 两条数据管线
+
+### 0.1 两条对接路(本指南核心)
+
+| 路 | 第三方 | 协议 | 典型用途 |
+|----|--------|------|----------|
+| **路 A · T8 / 本库 DB** | T8 SQL Server + 本库业务表 | DB 直连 | KPI 看板刷新;本库收货/IPQC/入库贴源入站 |
+| **路 B · MES+WMS API** | 第三方 MES / WMS(可用 Mock 代替) | HTTP API | 同一业务对象用 API 抽数,验与 DB 路键一致 |
+
+> **双模式交付含义**:同一业务对象在 `mdp_entity` 同时有 **DB 实体** 与 **`*_API` 实体**,落同一 `mdp_stg_*` → `mdp_std_*`;回写走 `mdp_outbox`,禁止前端直连外部写。
+
+> **回写机制(一句话)**:业务合格闭环时先幂等写入 `mdp_outbox`,再由定时 Job 按 `QMS_API` 配置把 payload 里的 `path/method/body` 用 HTTP 推给第三方,失败可重试、禁止前端直连外部写。
+
+### 0.2 S5–S7 两条管线(不要混测)
+
+| 管线 | 入口 API | 读什么 | 写什么 | 是否碰 T8 |
+|------|----------|--------|--------|-----------|
+| **业务贴源入站** | `…/s5-purchase-receipt-mdp/inbound` 等 | 本库表或 WMS/MES API | `mdp_stg_*` → `mdp_std_*` | 否(报工例外见 §4.2) |
+| **KPI 刷新** | `…/s5-mdp/refresh` 等 | **T8** 聚合 SQL | `ado_s9_kpi_*` / DWD | 是 |
+
+AIDOPB 等「无 T8」租户:**只测业务入站 + 造数**,不要对 `s5/s6/s7-mdp/refresh` 期望出 T8 KPI。
+
+---
+
+## 1. 环境与账号前置
+
+| 检查项 | 要求 | 如何确认 |
+|--------|------|----------|
+| 业务库 | MySQL `aidopdev` 已执行到 `1.0.264` | `SELECT entity_code FROM mdp_entity WHERE entity_code LIKE 'S6_REPORT%'` 有 2 行 |
+| 后端服务 | Admin.NET 已启动,插件含 AiDOP | 浏览器或 Postman 能访问 `/api/AidopKanban/...` |
+| 前端 | 登录后能开数据中台菜单 | `/aidop/data-platform/sources`、`/sync-tasks` |
+| Demo 租户(有 T8) | `TenantId = 797403760988229`,T8 账套 `ztid = pbxfxp` | 见 §6 租户映射 |
+| AIDOPB(无 T8) | `TenantId = 824585161322565`,用种子造数 | `aidopb_b2_*.sql` |
+| Mock(测 API 路) | `127.0.0.1:8018`,Token `uat-mock-token` | 见 §3.2 |
+
+API 前缀(控制器匿名开放,UAT 可直接调):
+
+```text
+POST /api/AidopKanban/<路由>
+```
+
+---
+
+## 2. 配置中心页面(操作入口)
+
+| 页面 | 路径 | 做什么 |
+|------|------|--------|
+| 数据源管理 | `/aidop/data-platform/sources` | 查看 `mdp_source`、健康状态 |
+| 同步配置中心 | `/aidop/data-platform/sync-tasks` | 任务 / 实体 / **调度策略**(FULL·INCR·ROLLING)/ 参数·公式 |
+| 任务日志 | `/aidop/data-platform/sync-logs` | 按 `jobCode` 查运行结果 |
+| 链路监视 | `/aidop/data-platform/mdp-monitor` | 运行与血缘辅助 |
+
+调度:在同步任务行 → **更多 → 调度策略**,设置:
+
+- `syncWindowType`:`FULL` / `INCR` / `ROLLING`
+- `syncWindowValue`:如 `30d`、`7d`(ROLLING 用)
+
+---
+
+## 3. 数据源配置(必做)
+
+### 3.1 当前库侧四类源(对照)
+
+在库中确认(或在「数据源管理」页查看):
+
+| source_code | 类型 | status | 用途 | UAT 注意 |
+|-------------|------|--------|------|----------|
+| `AIDOPDEV_MYSQL` | DB | **1** | S5 收货 / S6 IPQC / S7 入库 **业务入站 DB 路** | 本库,勿停用 |
+| `T8_V5_SQLSERVER` | DB | **1** | S5–S7 **KPI**;S6 报工 DB 实体 | 连不上则 KPI/报工 DB 路失败 |
+| `WMS_API` | API | **1** | MES/WMS **入站 API 路**(Mock 或真源) | 切真源只改 URL/Token |
+| `QMS_API` | API | **0(默认)** | IQC/IPQC/FQC **回写目标** | 真推前改 URL 后置 `status=1` |
+
+核对 SQL:
+
+```sql
+SELECT source_code, source_type, status, db_type, db_host, db_name,
+       api_base_url, api_auth_type, health_status, health_msg, last_health_check
+FROM mdp_source
+WHERE source_code IN ('AIDOPDEV_MYSQL','T8_V5_SQLSERVER','WMS_API','QMS_API');
+```
+
+### 3.2 启动 Mock(路 B 无真实 MES/WMS 时)
+
+```bat
+cd doc\db\mdp\mock_api
+pip install -r requirements.txt
+set MOCK_TOKEN=uat-mock-token
+python -m uvicorn mock_server:app --host 127.0.0.1 --port 8018
+```
+
+浏览器或 curl:
+
+```text
+GET http://127.0.0.1:8018/health
+GET http://127.0.0.1:8018/__endpoints
+```
+
+`WMS_API` 必须与 Mock 一致:
+
+```sql
+UPDATE mdp_source
+SET api_base_url = 'http://127.0.0.1:8018',
+    api_auth_type = 'TOKEN',
+    api_auth_config = JSON_OBJECT('token','uat-mock-token'),
+    status = 1,
+    update_time = NOW()
+WHERE source_code = 'WMS_API';
+```
+
+### 3.3 切到真实 MES / WMS(路 B 真源)
+
+由实施提供 BaseUrl 与 Token 后执行(示例):
+
+```sql
+UPDATE mdp_source
+SET api_base_url = 'https://<真实-MES-或-WMS-主机>/',   -- 无尾斜杠也可,执行器会拼 path
+    api_auth_type = 'TOKEN',
+    api_auth_config = JSON_OBJECT('token','<真实Token>'),
+    status = 1,
+    update_time = NOW()
+WHERE source_code = 'WMS_API';
+```
+
+真实接口契约(与 Mock / 执行器对齐,缺一不可):
+
+| 约定 | 值 |
+|------|-----|
+| 方法 | `GET` |
+| 鉴权 | `Authorization: Bearer <token>` |
+| 列表路径 | 响应 JSON 的 `data.list`(数组) |
+| 行去重键 | 每行字段 `bizKey`(与 `dedup_key_path` 一致) |
+| 业务字段 | 与对应 DB 源表字段同名(如 `Domain/Receiver/Line`、`djbh`、`noid/kgdate`) |
+
+端点与实体对照见 §4 表。
+
+### 3.4 切到真实 QMS(回写推送)
+
+```sql
+UPDATE mdp_source
+SET api_base_url = 'https://<真实-QMS>/',
+    api_auth_type = 'TOKEN',
+    api_auth_config = JSON_OBJECT('token','<Token>'),
+    status = 1,          -- 启用后 Outbox Job 才会真推
+    update_time = NOW()
+WHERE source_code = 'QMS_API';
+```
+
+`status=0` 时:业务仍会**写入** `mdp_outbox`,但推送 Job **不会**对外部成功投递——UAT 可先验「有 Outbox 行」,再开真推。
+
+回写机制、对方接口约定与 Postman/接单 Mock 见 **§5.1**。
+
+---
+
+## 4. S5–S7 实体与入站对照(测试必查表)
+
+### 4.1 业务对象 ↔ 实体 ↔ 贴源表
+
+| 模块 | 业务对象 | DB 实体 `entity_code` | API 实体 | Mock/真源 path | 贴源 stg | 标准层 std | 业务键 `biz_key_expr` |
+|------|----------|----------------------|----------|----------------|----------|------------|----------------------|
+| S5 | 采购收货明细 | `S5_PURCHASE_RECEIPT_DETAIL` | `…_DETAIL_API` | `/api/receipt` | `mdp_stg_purchase_receipt` | `mdp_std_purchase_receipt` | Domain,Receiver,Line |
+| S5 | 采购收货主 | `S5_PURCHASE_RECEIPT_MASTER` | `…_MASTER_API` | `/api/receipt-master` | 同上 | 同上 | Domain,Receiver |
+| S6 | IPQC 头 | `S6_IPQC_INSPECTION_HEAD` | `…_HEAD_API` | `/api/ipqc` | `mdp_stg_ipqc_pull` | `mdp_std_ipqc_inspection` | djbh |
+| S6 | 报工(T8/MES) | `S6_REPORT`(源=T8) | `S6_REPORT_API` | `/api/mes/report` | `mdp_stg_s6_report` | `mdp_std_s6_report` | noid,kgdate |
+| S7 | 成品入库主 | `S7_NBR_MASTER` | `…_MASTER_API` | `/api/production-receipt` | `mdp_stg_production_receipt` | `mdp_std_production_receipt` | Domain,Nbr |
+| S7 | 成品入库明细 | `S7_NBR_DETAIL` | `…_DETAIL_API` | `/api/production-receipt-detail` | 同上 | 同上 | Domain,Nbr,Line |
+
+核对实体:
+
+```sql
+SELECT e.entity_code, e.source_table_name, e.source_api_path, e.target_table_name,
+       e.biz_key_expr, e.sync_mode, e.status, s.source_code, s.source_type
+FROM mdp_entity e
+JOIN mdp_source s ON s.id = e.source_id
+WHERE e.entity_code IN (
+  'S5_PURCHASE_RECEIPT_DETAIL','S5_PURCHASE_RECEIPT_DETAIL_API',
+  'S5_PURCHASE_RECEIPT_MASTER','S5_PURCHASE_RECEIPT_MASTER_API',
+  'S6_IPQC_INSPECTION_HEAD','S6_IPQC_INSPECTION_HEAD_API',
+  'S6_REPORT','S6_REPORT_API',
+  'S7_NBR_MASTER','S7_NBR_MASTER_API',
+  'S7_NBR_DETAIL','S7_NBR_DETAIL_API'
+)
+ORDER BY e.entity_code;
+```
+
+### 4.2 入站 / KPI 接口一览(复制即用)
+
+**业务入站(贴源)**
+
+```http
+POST /api/AidopKanban/s5-purchase-receipt-mdp/inbound?tenantId=0&fullRefresh=true
+POST /api/AidopKanban/s6-ipqc-inspection-mdp/inbound?tenantId=0&fullRefresh=true
+POST /api/AidopKanban/s6-report-mdp/inbound?tenantId=0&fullRefresh=true
+POST /api/AidopKanban/s6-report-mdp/inbound?tenantId=0&fullRefresh=true&entityCode=S6_REPORT_API
+POST /api/AidopKanban/s7-production-receipt-mdp/inbound?tenantId=0&fullRefresh=true
+```
+
+| 参数 | 含义 |
+|------|------|
+| `tenantId` | 落库租户;业务样板常用 `0` 或 Demo 租户 |
+| `fullRefresh` | `true`=全量忽略水位 |
+| `entityCode` | **仅报工接口支持**;默认 `S6_REPORT`(T8),API 路传 `S6_REPORT_API` |
+
+**KPI(T8)**
+
+```http
+POST /api/AidopKanban/s5-mdp/refresh?targetTenantId=797403760988229&targetFactoryId=1
+POST /api/AidopKanban/s6-mdp/refresh?targetTenantId=797403760988229&targetFactoryId=1
+POST /api/AidopKanban/s7-mdp/refresh?targetTenantId=797403760988229&targetFactoryId=1
+```
+
+### 4.3 重要:收货 / IPQC / 入库 inbound 如何切到 API 路
+
+当前实现里,下列 inbound **代码写死 DB 实体编码**(不接 `entityCode` 参数):
+
+- `S5_PURCHASE_RECEIPT_DETAIL`(及主表由服务内部联动)
+- `S6_IPQC_INSPECTION_HEAD`
+- `S7_NBR_MASTER`(及明细)
+
+Dispatcher 规则:**实体上 `source_api_path` 非空,或所属 `mdp_source.source_type=API` → 走 API 执行器**。
+
+因此 UAT 测 **MES/WMS API 路** 有两种做法(二选一):
+
+#### 做法甲(推荐 · 临时改 DB 实体指向 API,测完改回)
+
+以 S5 收货为例(测前备份 `source_id` / `source_api_path`):
+
+```sql
+-- 切到 API 路(挂 WMS_API + path)
+UPDATE mdp_entity e
+JOIN mdp_source s ON s.source_code = 'WMS_API'
+SET e.source_id = s.id,
+    e.source_api_path = '/api/receipt',
+    e.update_time = NOW()
+WHERE e.entity_code = 'S5_PURCHASE_RECEIPT_DETAIL';
+
+-- 调用 inbound 后,改回本库 DB 路(示例:挂回 AIDOPDEV_MYSQL,清空 path)
+UPDATE mdp_entity e
+JOIN mdp_source s ON s.source_code = 'AIDOPDEV_MYSQL'
+SET e.source_id = s.id,
+    e.source_api_path = NULL,
+    e.update_time = NOW()
+WHERE e.entity_code = 'S5_PURCHASE_RECEIPT_DETAIL';
+```
+
+同理:
+
+| 测对象 | 改哪个 entity_code | 临时 `source_api_path` |
+|--------|-------------------|------------------------|
+| S5 收货 | `S5_PURCHASE_RECEIPT_DETAIL`(主表若一并抽则还有 MASTER) | `/api/receipt`(主:`/api/receipt-master`) |
+| S6 IPQC | `S6_IPQC_INSPECTION_HEAD` | `/api/ipqc` |
+| S7 入库 | `S7_NBR_MASTER`(明细 `S7_NBR_DETAIL`) | `/api/production-receipt`(明细:`…-detail`) |
+
+#### 做法乙(不改配置 · 跑契约脚本)
+
+不经过 inbound HTTP,直接用 Mock 载荷双路写 stg→std 比键:
+
+```bat
+cd doc\db\mdp\contract_tests
+set MOCK_TOKEN=uat-mock-token
+python run_contract_s5_s6_s7.py
+python run_contract_s6_report.py
+```
+
+适合「契约一致性」验收;**不等于**完整 inbound 联调。
+
+---
+
+## 5. 分场景操作步骤(给测试人员逐步勾选)
+
+### 场景 A · 对接 T8(DB):KPI + 报工
+
+**目标**:证明 T8 连通、账套映射正确、KPI / 报工能出数。
+
+1. [ ] 确认 `T8_V5_SQLSERVER.status=1`,网络可达(见 §3.1)。
+2. [ ] 确认租户映射:`pbxfxp` → `797403760988229`(§6);或 refresh 显式带 `targetTenantId`。
+3. [ ] 登录 Demo 租户(或用匿名 API)。
+4. [ ] 调用 KPI:
+   - `POST …/s5-mdp/refresh?targetTenantId=797403760988229&targetFactoryId=1`
+   - 同理 `s6-mdp/refresh`、`s7-mdp/refresh`
+5. [ ] 验收 KPI:
+
+```sql
+SELECT module_code, metric_code, metric_value, biz_date, tenant_id, calc_time
+FROM ado_s9_kpi_value_l1_day
+WHERE tenant_id = 797403760988229
+ORDER BY calc_time DESC
+LIMIT 50;
+```
+
+6. [ ] 报工 DB 路:`POST …/s6-report-mdp/inbound?fullRefresh=true`(默认 `S6_REPORT`→T8 `Cj_Bg_Head_Rep`)。
+7. [ ] 验收报工:
+
+```sql
+SELECT source_system, source_biz_key, process_status, sync_batch_id, sync_time
+FROM mdp_stg_s6_report
+ORDER BY id DESC LIMIT 20;
+
+SELECT work_order_no, report_date, report_qty, source_biz_key, sync_batch_id
+FROM mdp_std_s6_report
+ORDER BY id DESC LIMIT 20;
+```
+
+**通过标准**:refresh 不抛「未找到 ztid 映射」;KPI 或报工至少一侧有新增/更新行(T8 当日无业务时允许 0 行但接口 `ok=true`)。
+
+---
+
+### 场景 B · 对接本库 DB:S5 收货 / S6 IPQC / S7 入库
+
+**目标**:本库有源数据时,业务入站打通 stg→std。
+
+1. [ ] 确认 `AIDOPDEV_MYSQL.status=1`,对应 DB 实体 `status=1` 且 `source_api_path` 为空。
+2. [ ] (可选)灌样板:`doc/db/mdp/s5_dual_mode_sample_seed.sql`、`s6_s7_dual_mode_sample_seed.sql`;AIDOPB 用 `aidopb_b2_*.sql`。
+3. [ ] 依次调用:
+
+```http
+POST /api/AidopKanban/s5-purchase-receipt-mdp/inbound?fullRefresh=true
+POST /api/AidopKanban/s6-ipqc-inspection-mdp/inbound?fullRefresh=true
+POST /api/AidopKanban/s7-production-receipt-mdp/inbound?fullRefresh=true
+```
+
+4. [ ] 验收(示例 S5):
+
+```sql
+SELECT source_system, source_biz_key, process_status, sync_batch_id
+FROM mdp_stg_purchase_receipt
+ORDER BY id DESC LIMIT 20;
+
+SELECT source_system, source_biz_key, receiver, item_num, qty_received, sync_batch_id
+FROM mdp_std_purchase_receipt
+ORDER BY id DESC LIMIT 20;
+```
+
+S6 / S7 将表名换成 `mdp_stg_ipqc_pull` / `mdp_std_ipqc_inspection`、`mdp_stg_production_receipt` / `mdp_std_production_receipt`。
+
+5. [ ] 配置中心:打开任务 `S5_PURCHASE_RECEIPT_INBOUND` / `S6_IPQC_INSPECTION_INBOUND` / `S7_PRODUCTION_RECEIPT_INBOUND` → 调度设 `FULL` 或 `ROLLING`+`30d`,再手工触发或等 Job。
+
+**通过标准**:接口返回 `ok=true`;stg 有 `PENDING→DONE`;std 出现对应 `source_biz_key`。源表 0 行时允许处理数为 0。
+
+---
+
+### 场景 C · 对接第三方 MES+WMS(API):Mock 或真源
+
+**目标**:API 路能落同一 stg/std,且业务键与 DB 路一致。
+
+#### C1 · Mock 联调(无真系统时)
+
+1. [ ] 启动 Mock(§3.2),确认 `WMS_API` 指向 `8018`。
+2. [ ] **报工 API**(最简单,支持 `entityCode`):
+
+```http
+POST /api/AidopKanban/s6-report-mdp/inbound?fullRefresh=true&entityCode=S6_REPORT_API
+```
+
+3. [ ] **收货 / IPQC / 入库**:按 §4.3 做法甲临时改实体 → 调对应 inbound → **改回 DB**。
+4. [ ] 或跑契约脚本(§4.3 做法乙)。
+5. [ ] 对比键(手工):
+
+```sql
+-- 将 BATCH_DB / BATCH_API 换成实际 sync_batch_id
+SELECT source_biz_key FROM mdp_std_purchase_receipt WHERE sync_batch_id = 'BATCH_DB'
+ORDER BY 1;
+SELECT source_biz_key FROM mdp_std_purchase_receipt WHERE sync_batch_id = 'BATCH_API'
+ORDER BY 1;
+-- 两集合应一致(契约脚本已自动化此断言)
+```
+
+#### C2 · 真实 MES/WMS
+
+1. [ ] 按 §3.3 改 `WMS_API` 的 URL/Token。
+2. [ ] 用 Postman 先直连对方接口,确认 `data.list` + `bizKey` + 业务字段。
+3. [ ] 再按 C1 步骤走 inbound / 契约。
+4. [ ] 记录对方环境、账号、样例 `bizKey`、成功 `sync_batch_id` 到 UAT 证据表。
+
+**通过标准**:API 路 std 非空(有源时);与 DB 路(或契约双路)`source_biz_key` 集合一致;`mdp_sync_log` / 接口响应无鉴权失败。
+
+---
+
+### 场景 D · 回写 Outbox(IQC / IPQC / FQC → QMS)
+
+> 机制详见下方 **§5.1**;本场景只列操作勾选。
+
+| action_code | 触发业务动作 | target_source |
+|-------------|--------------|---------------|
+| `S5_IQC_RESULT_PUSH` | IQC 检验单主管通过且判定**合格** | `QMS_API` |
+| `S6_REPORT_PUSH` | IPQC 主管判定合格结束 | `QMS_API` |
+| `S7_FQC_RESULT_PUSH` | FQC 主管通过且合格 | `QMS_API` |
+
+步骤:
+
+1. [ ] 在对应模块完成一张合格闭环单据(租户正确)。
+2. [ ] 查 Outbox(SQL 见 §5.1.4)。
+3. [ ] 确认 `status=0` 且 `payload_json` 含 `path/method/body`。
+4. [ ] 真推:§3.4 启用 `QMS_API` → 等 `MdpOutboxPushJob`(约 60s)→ `status=1`。
+5. [ ] 幂等:同一单据再点一次,不应新增相同 `idem_key`。
+6. [ ] (可选)用 Postman 模拟对方接单端,核对 URL/Body(§5.1.5)。
+
+**通过标准**:合格闭环后必有 Outbox 行;重复操作不双插;`QMS_API.status=0` 时允许长期待推但不报业务失败;启用后 HTTP 2xx 且 `status=1`。
+
+---
+
+## 5.1 回写给第三方:机制 · 对方约定 · Postman
+
+### 5.1.1 机制总览
+
+```text
+业务合格闭环(IQC/IPQC/FQC 主管通过)
+        │
+        ▼  幂等 INSERT(失败不阻断业务)
+   mdp_outbox(status=0 待推)
+        │
+        ▼  MdpOutboxPushJob 约每 60 秒扫描
+   MdpApiPushExecutor
+        │  读 mdp_source(target_source_code,默认 QMS_API,须 status=1)
+        │  解析 payload_json:{ path, method, body }
+        ▼
+   HTTP  method  →  api_base_url + path
+   Header: Authorization: Bearer <token>(api_auth_type=TOKEN/BEARER)
+   Body:   payload.body(JSON)
+        │
+        ▼
+   对方系统返回 2xx → outbox.status=1,记下 response_json
+   非 2xx / 异常 → retry_count+1;未满 3 次保持 status=0 待重试;满 3 次 status=2 失败
+```
+
+**硬约束**:前端 / 看板 **禁止** 直连第三方写接口;只允许业务服务写 Outbox + Job 推送。
+
+### 5.1.2 payload 约定(对方必须按此接)
+
+`mdp_outbox.payload_json` 固定形状:
+
+```json
+{
+  "path": "/iqc/result",
+  "method": "POST",
+  "body": { "...业务字段..." }
+}
+```
+
+执行器实际请求:
+
+| 项 | 规则 |
+|----|------|
+| URL | `{QMS_API.api_base_url}` + `{path}`(path 以 `/` 开头) |
+| Method | payload.`method`,默认 `POST` |
+| Auth | `TOKEN`/`BEARER`:`Authorization: Bearer <api_auth_config.token>`;另支持 BASIC / APIKEY |
+| Content-Type | `application/json` |
+| Body | 仅发送 `body` 对象(不是整个 payload) |
+
+### 5.1.3 S5–S7 当前已接线的回写动作
+
+| action_code | 触发条件 | idem_key | 实际 HTTP | body 主要字段 |
+|-------------|----------|----------|-----------|---------------|
+| `S5_IQC_RESULT_PUSH` | IQC 主管通过且 `pd=0`(合格) | 检验单号 `FBILLNO` | `POST {base}/iqc/result` | `billId, billNo, pd, dhsl, bhgsl, action` |
+| `S6_REPORT_PUSH` | IPQC 整批合格结束 | 单据号 `djbh` | `POST {base}/ipqc/result` | `billId, billNo, jgpd, jyhgsl, jybhgsl, action` |
+| `S7_FQC_RESULT_PUSH` | FQC 主管通过且 `pd=0`(合格) | 检验单号 `FBILLNO` | `POST {base}/fqc/result` | `billId, billNo, pd, hgsl, bhgsl, action` |
+
+> 名称说明:任务书里 S6 出站 action 叫 `S6_REPORT_PUSH`,当前实装挂在 **IPQC 合格** 闭环(不是报工入库)。报工入站是 `S6_REPORT` / `S6_REPORT_API`,与此出站不同。
+
+对方系统需提供(或 Mock)上述三个 path;鉴权方式与 `QMS_API` 配置一致。
+
+### 5.1.4 Outbox 状态与验收 SQL
+
+| status | 含义 |
+|--------|------|
+| `0` | 待推 / 重试中 |
+| `1` | 推送成功(HTTP 2xx) |
+| `2` | 最终失败(重试 ≥ 3 或目标源未配置) |
+
+```sql
+SELECT id, tenant_id, target_source_code, action_code, idem_key,
+       status, retry_count, error_msg,
+       payload_json, LEFT(IFNULL(response_json,''), 300) AS resp_preview,
+       create_time, update_time
+FROM mdp_outbox
+WHERE action_code IN ('S5_IQC_RESULT_PUSH','S6_REPORT_PUSH','S7_FQC_RESULT_PUSH')
+ORDER BY id DESC
+LIMIT 30;
+```
+
+启用真推前:
+
+```sql
+-- 目标源必须 status=1 且 api_base_url 可达
+SELECT source_code, status, api_base_url, api_auth_type, api_auth_config
+FROM mdp_source WHERE source_code = 'QMS_API';
+```
+
+### 5.1.5 对方接口约定(给 MES/QMS 厂商)
+
+请满足:
+
+1. 提供 BaseUrl,例如 `https://qms.partner.local/api`。
+2. 实现(或网关转发)至少:
+   - `POST /iqc/result`
+   - `POST /ipqc/result`
+   - `POST /fqc/result`
+3. 鉴权:Bearer Token(与我方 `mdp_source.api_auth_config.token` 一致)。
+4. 成功返回 **HTTP 2xx**(建议 `{"code":0,"message":"ok"}`);非 2xx 我方会重试最多 3 次。
+5. 按 `body.billNo`(或 `billId`)做幂等,避免我方重试导致对方重复记账。
+
+样例请求(S5 IQC,Postman 可先手工打对方):
+
+```http
+POST https://<QMS-BaseUrl>/iqc/result
+Authorization: Bearer <token>
+Content-Type: application/json
+
+{
+  "billId": 123456789,
+  "billNo": "IQC20260724001",
+  "pd": 0,
+  "dhsl": 100,
+  "bhgsl": 0,
+  "action": "S5_IQC_RESULT_PUSH"
+}
+```
+
+### 5.1.6 UAT 用 Postman / 本地 Mock 接单(可选)
+
+**目的**:在没有真实 QMS 时,验证 Job 真的发出了 HTTP。
+
+1. 用任意 HTTP 接单工具监听(示例:Postman Mock Server、webhook.site,或本机临时服务)。
+2. 把 `QMS_API` 指过去并 `status=1`(§3.4)。
+3. 走一张 IQC/IPQC/FQC **合格**闭环。
+4. 约 1 分钟内:接单端收到 `POST …/iqc|ipqc|fqc/result`;库中对应 Outbox `status=1`。
+5. 若一直 `status=0`:查 Job 是否调度、`QMS_API.status`、`error_msg`、网络出站。
+
+本地快速 Mock 接单示例(Python,仅 UAT):
+
+```python
+# 另开终端:python -m pip install flask && python qms_mock_receiver.py
+from flask import Flask, request
+app = Flask(__name__)
+
+@app.post("/iqc/result")
+@app.post("/ipqc/result")
+@app.post("/fqc/result")
+def recv():
+    print(request.path, request.get_json())
+    return {"code": 0, "message": "ok"}
+
+if __name__ == "__main__":
+    app.run(host="0.0.0.0", port=8020)
+```
+
+```sql
+UPDATE mdp_source
+SET api_base_url = 'http://127.0.0.1:8020',
+    api_auth_type = 'TOKEN',
+    api_auth_config = JSON_OBJECT('token','uat-mock-token'),
+    status = 1,
+    update_time = NOW()
+WHERE source_code = 'QMS_API';
+```
+
+### 5.1.7 与入站的区别(避免测混)
+
+| | 入站(第三方 → DOP) | 回写(DOP → 第三方) |
+|--|---------------------|----------------------|
+| 配置源 | `WMS_API` / 本库 / T8 | `QMS_API` |
+| 载体 | `mdp_entity` + Pull 执行器 | `mdp_outbox` + Push 执行器 |
+| 触发 | inbound API / 同步任务 | 业务合格闭环 |
+| 方向 | GET 拉 `data.list` | POST 推 `body` |
+
+---
+
+## 6. 租户与 T8 账套映射(KPI / 报工必读)
+
+| 项 | 值 |
+|----|-----|
+| 内置映射 | `pbxfxp` → `797403760988229`(Demo / AIDOP) |
+| 配置覆盖 | `appsettings` 段 `AidopMdp:ZtidTenantMap`,例如 `{ "pbxfxp": "797403760988229" }` |
+| 显式覆盖 | refresh 传 `targetTenantId>0` |
+| 失败策略 | **未映射 ztid 直接报错,绝不回退固定租户** |
+
+AIDOPB(`824585161322565`)无 T8:不要用它的租户去跑依赖 T8 的 KPI,除非单独配置了 ztid 映射。
+
+---
+
+## 7. 验收清单(S5–S7 签字用)
+
+| # | 检查项 | 路 | 证据 | 结果 |
+|---|--------|----|------|------|
+| 1 | `AIDOPDEV_MYSQL` / `T8_V5_SQLSERVER` / `WMS_API` 状态正确 | A/B | §3.1 SQL 截图 | ☐ |
+| 2 | S5 收货 inbound → stg/std | A | batch_id + 行数 | ☐ |
+| 3 | S6 IPQC inbound → stg/std | A | 同上 | ☐ |
+| 4 | S7 入库 inbound → stg/std | A | 同上 | ☐ |
+| 5 | S5–S7 KPI refresh(Demo 租户) | A·T8 | KPI SQL | ☐ |
+| 6 | S6 报工 T8 inbound | A·T8 | stg/std | ☐ |
+| 7 | S6 报工 API inbound(Mock 或真源) | B | `entityCode=S6_REPORT_API` | ☐ |
+| 8 | S5/S6/S7 API 路(改实体或契约脚本)与 DB 键一致 | B | 契约 PASS 或键对比 | ☐ |
+| 9 | IQC/IPQC/FQC Outbox 写入 | A | `mdp_outbox` | ☐ |
+| 10 | (可选)QMS 真推成功 | B | Outbox status 成功 | ☐ |
+| 11 | 调度 FULL/INCR/ROLLING 各验一次(任选一任务) | A | 调度抽屉 + 日志 | ☐ |
+
+自动化辅助:
+
+```bat
+cd doc\db\mdp\contract_tests
+python run_contract_s5_s6_s7.py
+python run_contract_s6_report.py
+python e2e_api_to_std_s5.py
+python e2e_api_to_std_s6.py
+python e2e_api_to_std_s7.py
+```
+
+(需本机 Python、`pymysql`/`requests`、Mock 已起、库账号环境变量见 `_db.py`。)
+
+---
+
+## 8. 常见问题
+
+| 现象 | 原因 | 处理 |
+|------|------|------|
+| inbound 仍读本库,不走 Mock | 实体仍挂 `AIDOPDEV_MYSQL` 且无 `source_api_path` | 按 §4.3 临时改实体,或只测 `S6_REPORT_API` |
+| API 401 | Token 与 `api_auth_config` / `MOCK_TOKEN` 不一致 | 三者改成同一值 |
+| KPI 报「未找到 ztid 映射」 | 账套未配置 | 配 `AidopMdp:ZtidTenantMap` 或传 `targetTenantId` |
+| Outbox 有行但不推 | `QMS_API.status=0` | 预期;真推前 §3.4 |
+| T8 超时 / 连不上 | 防火墙或账号 | 先 DBA 测 `T8_V5_SQLSERVER` 连接;KPI/报工 DB 路暂停 |
+| AIDOPB 无 KPI | D3:无 T8 | 用业务种子测入站,不测 T8 refresh |
+| 契约脚本连库失败 | 默认连 `127.0.0.1` | 设环境变量 `MDP_DB_HOST/PORT/USER/PASSWORD/NAME` |
+| `S6_REPORT` 实体不存在 | 未执行 `1.0.264` 或无 T8 源 | 执行 UpdateScript;确认 `T8_V5_SQLSERVER` 存在 |
+
+---
+
+## 9. 测试记录建议字段(粘贴到用例系统)
+
+```text
+环境:aidopdev / 前端版本 / 后端版本
+租户:________    ztid:________
+路:□ T8-DB  □ 本库-DB  □ Mock-API  □ 真MES/WMS-API
+对象:□ S5收货 □ S6-IPQC □ S6报工 □ S7入库 □ KPI □ Outbox
+请求:方法/URL/参数
+sync_batch_id:________
+stg 行数 / std 行数 / 样例 source_biz_key:
+Outbox action / idem_key / status:
+结论:□ 通过 □ 失败  问题单:________
+```
+
+---
+
+## 10. 相关文件索引
+
+| 类型 | 路径 |
+|------|------|
+| 交付任务书 | `doc/plan/AIDOP双模式全模块对接交付级任务书.md` |
+| Mock | `doc/db/mdp/mock_api/` |
+| 契约 / e2e | `doc/db/mdp/contract_tests/` |
+| 样板种子 | `doc/db/mdp/s5_dual_mode_sample_seed.sql`、`s6_s7_dual_mode_sample_seed.sql` |
+| DDL | `server/Admin.NET.Web.Entry/UpdateScripts/1.0.259.sql` … `1.0.264.sql` |
+| 租户映射 | `server/Plugins/.../Infrastructure/AidopSourceTenantMap.cs` |
+| 入站 API | `server/Plugins/.../Controllers/AidopKanbanController.cs` |

+ 385 - 0
doc/plan/AIDOP双模式S5-S7独立SQLServer跨库回写方案与任务书.md

@@ -0,0 +1,385 @@
+# AIDOP 双模式 S5–S7 独立 SQLServer 跨库回写|方案与任务书
+
+> 适用范围:S5 来料检验 / S6 生产制造 / S7 成品检验 与**独立 SQLServer 的第三方 MES+WMS** 的双向对接。
+> 前提约束:**第三方 MES+WMS 不做任何程序修改,只能改它读取/写入的数据库**(即只能在 SQLServer 侧改表结构,不能改其应用逻辑)。
+> 本文可交由第三方大模型/开发照此执行。凡「任务 X」小节都给出**明确交付物 + 验收点**。
+
+---
+
+## 0. TL;DR(一句话)
+
+旧模式是「MES+WMS 接口表建在 DOP 库、DOP 直读直写」;现在 MES+WMS 是**独立 SQLServer**,所以改为:**入站沿用现成的 `MdpDbPullExecutor` 读对方 SQLServer 表 → `mdp_stg/std`;回写则新增 `MdpDbPushExecutor`,把 `mdp_outbox` 待推记录用 `MdpSourceScopeFactory` 直连对方 SQLServer 做幂等 upsert 写入接口表**——第三方只需把它的读取连接指向这些由 DOP 维护的接口表。
+
+---
+
+## 1. 背景与现状
+
+### 1.1 旧 DOP 的集成形态(共享库/接口表)
+- MES+WMS 的接口表**物理建在旧 DOP 库**;DOP 直接 `SELECT/INSERT/UPDATE`,双方共享同库。
+- 入站:MES/WMS 写接口表(收货、报工、入库),DOP 读。
+- 回写:DOP 写接口表(检验结果、确认状态),MES/WMS 读。
+
+### 1.2 新的事实(本次确认)
+- **第三方 MES+WMS 是另外的 SQLServer 数据库**,与 DOP(MySQL `aidopdev`)不同实例。
+- 约束:MES+WMS 程序不能改,**只能改它连接/读取的库**——因此接口表的**表名、字段、类型必须由 DOP 维护并保持对方可识别**,对方只 repoint 连接过来读。
+
+### 1.3 现有能力(已核对代码,可直接复用)
+| 能力 | 位置 | 状态 |
+|------|------|------|
+| 动态连外库(含 SQLServer,返回**可写** scope) | `DataPlatform/MdpSourceScopeFactory.cs` `GetScopeAsync(sourceCode, ct)` | ✅ |
+| DB 入站抽数(含 SQLServer 分页/游标/防注入) | `DataPlatform/Executors/MdpDbPullExecutor.cs`(`SupportedType=DB_SYNC`) | ✅ |
+| 回写底座(幂等键/重试/回执) | `Entity/DataPlatform/MdpOutbox.cs`(`target_source_code/action_code/idem_key/payload_json/status/retry_count/response_json`) | ✅ |
+| Outbox 扫描作业 | `Job/MdpOutboxPushJob.cs`(每 60s) | ✅ 但只调 API |
+| 回写执行器 | `DataPlatform/Executors/MdpApiPushExecutor.cs` | ⚠️ **仅 HTTP**,无 DB 分支 |
+| 源类型分发 | `MdpOutboxPushJob` 硬编码 `MdpApiPushExecutor` | ❌ 无 API/DB 分发 |
+| **DB 型回写执行器** | — | ❌ **本任务书新建** |
+
+### 1.4 唯一缺口
+回写链路 `MdpOutboxPushJob → MdpApiPushExecutor` 写死走 HTTP(要求 `source.ApiBaseUrl`,payload 约定 `{path, method, body}`),**不会写 DB**。本任务书补齐「DB 型回写」并在 Job 中按源类型分发。
+
+---
+
+## 2. 总体方案(数据流)
+
+```
+入站(MES/WMS SQLServer → DOP)
+  [SQLServer 业务/输出表] --MdpDbPullExecutor--> mdp_stg_* --transform--> mdp_std_* --> 看板/业务
+
+回写(DOP → MES/WMS SQLServer 接口表)
+  合格闭环(S5/S6/S7 Service) --写--> mdp_outbox(status=0, target=DB源)
+      └── MdpOutboxPushJob(每60s) --按源类型分发--> MdpDbPushExecutor
+              └── MdpSourceScopeFactory.GetScopeAsync(SQLServer) --幂等 upsert--> [SQLServer 接口表]
+                     └── MES/WMS repoint 连接 --读--> 接口表
+```
+
+### 2.1 接口库放置与所有权(关键约束,务必遵守)
+- **接口表 DDL 由 DOP 侧维护**(对方不改程序),建在**对方 SQLServer**里(对方可读、DOP 有写权限的 schema,建议独立 `dop_if` schema)。
+- **禁止双写冲突(WP0 铁律)**:
+  - **入站表**(MES/WMS 写、DOP 只读)与**回写表**(DOP 写、MES/WMS 只读)**必须分开**,绝不两边写同一字段/同一行。
+  - 每张回写接口表标注 owner=DOP;MES/WMS 只 `SELECT`。
+- **幂等**:回写以业务单号(如 `bill_no`)为主键/唯一键,重复不产生重复行(upsert)。
+- **最终一致**:跨库无本地事务,靠 `mdp_outbox.status/retry_count` 重试兜底。
+- **防注入**:表名/列名走标识符白名单正则校验,值全部走 `SugarParameter` 参数化。
+
+---
+
+## 3. 回写 Payload 约定(DB 型)
+
+现有 **API 型** payload(保持不变):
+```json
+{ "path": "/fqc/result", "method": "POST", "body": { "billNo": "...", "pd": 0 } }
+```
+
+**新增 DB 型** payload(`target_source_code` 指向一个 `source_type=DB` 的源时使用):
+```json
+{
+  "target": "DB",
+  "table": "dop_if.dop_fqc_result",
+  "keys": ["bill_no"],
+  "set": {
+    "bill_no": "FQC20260724001",
+    "pd": 0,
+    "hgsl": 100,
+    "bhgsl": 0,
+    "result_time": "2026-07-24 15:00:00",
+    "src_system": "AIDOP"
+  },
+  "mode": "upsert"
+}
+```
+- `table`:`schema.table`,须匹配 `^[A-Za-z0-9_\.\[\]]+$`。
+- `keys`:定位行的键列(用于 upsert 命中)。
+- `set`:要写入的列=值(含 keys 列)。列名须匹配 `^[A-Za-z0-9_]+$`。
+- `mode`:`upsert`(默认)| `insert` | `update`。
+
+**分发规则**:`MdpOutboxPushJob` 读 `mdp_outbox` 待推 → 查其 `target_source_code` 对应 `mdp_source.source_type`:`DB` → `MdpDbPushExecutor`;否则 `MdpApiPushExecutor`。
+
+---
+
+## 4. 任务清单(可执行)
+
+> 后端命名空间统一 `Admin.NET.Plugin.AiDOP.*`。所有新增文件遵循现有风格(`ITransient`、`ISqlSugarClient _db`)。
+
+### 任务 A:新增 `MdpDbPushExecutor`(DB 型回写执行器)
+**交付物**:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs`
+
+**职责**:扫描 `mdp_outbox` 里 `target_source_code` 为 **DB 源**的待推记录,解析 DB 型 payload,用 `MdpSourceScopeFactory.GetScopeAsync` 连对方 SQLServer 执行幂等 upsert,写回执/重试(语义与 `MdpApiPushExecutor` 对齐:`MaxRetry=3`,`status` 0待推/1成功/2失败)。
+
+**骨架**(照此实现,注意防注入与参数化):
+```csharp
+using System.Text;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
+/// <summary>出站回写(DB 型):把 mdp_outbox 待推记录 upsert 进目标库接口表。</summary>
+public sealed class MdpDbPushExecutor : ITransient
+{
+    private const int MaxRetry = 3;
+    private static readonly System.Text.RegularExpressions.Regex TableRe = new(@"^[A-Za-z0-9_\.\[\]]+$");
+    private static readonly System.Text.RegularExpressions.Regex ColRe = new(@"^[A-Za-z0-9_]+$");
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourceScopeFactory _scopeFactory;
+
+    public MdpDbPushExecutor(ISqlSugarClient db, MdpSourceScopeFactory scopeFactory)
+    {
+        _db = db;
+        _scopeFactory = scopeFactory;
+    }
+
+    public async Task<(int success, int failed, int skipped)> PushPendingAsync(
+        IReadOnlyList<MdpOutbox> pending, CancellationToken ct = default)
+    {
+        int success = 0, failed = 0, skipped = 0;
+        foreach (var item in pending)
+        {
+            ct.ThrowIfCancellationRequested();
+            try
+            {
+                var scope = await _scopeFactory.GetScopeAsync(item.TargetSourceCode, ct);
+                using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(item.PayloadJson) ? "{}" : item.PayloadJson!);
+                var root = doc.RootElement;
+
+                var table = root.GetProperty("table").GetString() ?? throw new InvalidOperationException("payload 缺 table");
+                if (!TableRe.IsMatch(table)) throw new InvalidOperationException($"非法 table:{table}");
+                var mode = root.TryGetProperty("mode", out var mEl) ? (mEl.GetString() ?? "upsert") : "upsert";
+                var keys = root.TryGetProperty("keys", out var kEl) && kEl.ValueKind == JsonValueKind.Array
+                    ? kEl.EnumerateArray().Select(x => x.GetString()!).ToList() : new List<string>();
+                var setEl = root.GetProperty("set");
+
+                var affected = await UpsertAsync(scope, table, keys, setEl, mode, ct);
+                await MarkAsync(item, 1, $"{{\"affected\":{affected}}}", null, ct);
+                success++;
+            }
+            catch (Exception ex)
+            {
+                item.RetryCount++;
+                var status = item.RetryCount >= MaxRetry ? 2 : 0;
+                await MarkAsync(item, status, null, Truncate(ex.Message, 900), ct);
+                if (status == 2) failed++; else skipped++;
+            }
+        }
+        return (success, failed, skipped);
+    }
+
+    private static async Task<int> UpsertAsync(
+        ISqlSugarClient scope, string table, List<string> keys, JsonElement setEl, string mode, CancellationToken ct)
+    {
+        var cols = new List<string>();
+        var pars = new List<SugarParameter>();
+        foreach (var prop in setEl.EnumerateObject())
+        {
+            if (!ColRe.IsMatch(prop.Name)) throw new InvalidOperationException($"非法列名:{prop.Name}");
+            cols.Add(prop.Name);
+            pars.Add(new SugarParameter("@" + prop.Name, JsonValueToClr(prop.Value)));
+        }
+        if (cols.Count == 0) throw new InvalidOperationException("payload.set 为空");
+        foreach (var k in keys) if (!ColRe.IsMatch(k)) throw new InvalidOperationException($"非法键列:{k}");
+
+        // 先 UPDATE(命中 keys),rowcount==0 再 INSERT —— 跨 SQLServer/MySQL 通用,避免 MERGE 方言差异
+        int affected = 0;
+        if (mode is "upsert" or "update" && keys.Count > 0)
+        {
+            var setClause = string.Join(", ", cols.Where(c => !keys.Contains(c)).Select(c => $"{c}=@{c}"));
+            var whereClause = string.Join(" AND ", keys.Select(k => $"{k}=@{k}"));
+            if (!string.IsNullOrEmpty(setClause))
+                affected = await scope.Ado.ExecuteCommandAsync($"UPDATE {table} SET {setClause} WHERE {whereClause}", pars.ToArray());
+            if (mode == "update") return affected;
+        }
+        if (affected == 0 && mode is "upsert" or "insert")
+        {
+            var colList = string.Join(", ", cols);
+            var valList = string.Join(", ", cols.Select(c => "@" + c));
+            affected = await scope.Ado.ExecuteCommandAsync($"INSERT INTO {table} ({colList}) VALUES ({valList})", pars.ToArray());
+        }
+        return affected;
+    }
+
+    private static object JsonValueToClr(JsonElement el) => el.ValueKind switch
+    {
+        JsonValueKind.Null => DBNull.Value,
+        JsonValueKind.True => true,
+        JsonValueKind.False => false,
+        JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),
+        _ => (object?)el.GetString() ?? DBNull.Value
+    };
+
+    private async Task MarkAsync(MdpOutbox item, int status, string? resp, string? err, CancellationToken ct)
+    {
+        item.Status = status;
+        item.ResponseJson = Truncate(resp, 4000);
+        item.ErrorMsg = err;
+        item.UpdateTime = DateTime.Now;
+        await _db.Updateable(item)
+            .UpdateColumns(x => new { x.Status, x.RetryCount, x.ResponseJson, x.ErrorMsg, x.UpdateTime })
+            .ExecuteCommandAsync(ct);
+    }
+
+    private static string? Truncate(string? s, int max) =>
+        string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
+}
+```
+**验收点**:给定一条 DB 型 outbox(指向 SQLServer 源)→ 执行后目标接口表出现/更新对应行;`mdp_outbox.status=1`,`response_json` 含 `affected`;重复执行同 `idem_key` 不新增行。
+
+---
+
+### 任务 B:`MdpOutboxPushJob` 按源类型分发
+**交付物**:改 `server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxPushJob.cs`
+
+**逻辑**:一次扫描 `status=0 AND retry_count<3` 的待推批(如 50 条),按 `target_source_code` 关联 `mdp_source.source_type` 分成两组:
+- `source_type='DB'` → `MdpDbPushExecutor.PushPendingAsync(dbGroup)`
+- 其余(API)→ `MdpApiPushExecutor.PushPendingAsync(...)`(保持现有一条条处理即可,或改为传入分组)
+
+**注意**:现 `MdpApiPushExecutor.PushPendingAsync(take)` 自己查询待推。为避免两个执行器重复取同批,建议:Job 先统一查询待推列表 → join `mdp_source` 拿 `source_type` → 分组 → 分别调用。可给 `MdpApiPushExecutor` 增加一个接受 `IReadOnlyList<MdpOutbox>` 的重载,与 `MdpDbPushExecutor` 对齐(不破坏现有无参重载)。
+
+**验收点**:同时存在 API 型与 DB 型待推时,各自被正确执行器处理,互不串源。
+
+---
+
+### 任务 C:`mdp_source` 登记对方 SQLServer
+**交付物**:种子/配置 SQL(`UpdateScripts/<新版本>.sql` 或运维脚本),示例:
+```sql
+INSERT INTO mdp_source
+  (tenant_id, source_code, source_name, source_type, status,
+   db_type, db_host, db_port, db_name, db_user, db_password_enc, db_extra_params,
+   create_time, update_time)
+VALUES
+  (@tenantId, 'MES_WMS_SQLSERVER', '第三方MES+WMS(SQLServer)', 'DB', 1,
+   'SQLSERVER', '10.0.0.50', 1433, 'MES_WMS', 'dop_rw', '<密文或明文>', NULL,
+   NOW(), NOW());
+```
+**说明**:`db_password_enc` 走 `CryptogramUtil.Encrypt`;若明文亦可(`MdpSourceScopeFactory.DecryptPassword` 兼容明文)。`MdpSourceScopeFactory` 会以 `ConfigId=mdp-src-MES_WMS_SQLSERVER` 动态注册连接。
+
+**验收点**:`GET api/DataPlatform/sources` 能看到该源;健康检查 Job 能对其 `SELECT 1` 成功。
+
+---
+
+### 任务 D:对方 SQLServer 接口表 DDL(DOP 维护)
+**交付物**:SQLServer DDL 脚本(在对方库执行),入站/回写分离。示例(S7 回写表):
+```sql
+-- 回写接口表:owner=DOP,MES/WMS 只读
+CREATE TABLE dop_if.dop_fqc_result (
+    bill_no      NVARCHAR(50)  NOT NULL PRIMARY KEY,   -- 幂等键
+    pd           INT           NULL,                    -- 0合格/1不合格
+    hgsl         DECIMAL(18,4) NULL,
+    bhgsl        DECIMAL(18,4) NULL,
+    result_time  DATETIME      NULL,
+    src_system   NVARCHAR(20)  NULL DEFAULT 'AIDOP',
+    sync_time    DATETIME      NULL DEFAULT GETDATE()
+);
+```
+**要求**:为 S5(IQC 结果)、S6(报工/IPQC 结果)、S7(FQC 结果)各建对应回写表;入站表(MES/WMS 输出)单独列出且 DOP 只读。**表名/列名与对方约定一致,且不与入站表重叠。**
+
+**验收点**:接口表清单(表名、owner、键、方向)落档;对方 repoint 后可 `SELECT`。
+
+---
+
+### 任务 E:`mdp_entity` 入站登记(读对方 SQLServer)
+
+> ⚠️ **重要前提(勿踩坑)**:S5/S6/S7 现状**不是**通用 `mdp_stg/std` 入站。看代码注释:
+> `S5/S6/S7MdpSyncTransformService.cs` 开头写明「一期不做 `mdp_stg_t8_*` 贴源层;结果直接落 `dwd_t8_*` 与 `ado_s9_kpi_value_l1_day`」——即它们是**从 T8 直算 KPI 落 `dwd_t8_*`**。当前仓库里走 `mdp_stg/std` 入站的只有 **S1(`mdp_stg_so/mdp_std_so`)** 和后补的 **`S6_REPORT`(`mdp_stg_s6_report/mdp_std_s6_report`)**。
+> 所以对接 MES+WMS SQLServer 的入站**没有"现成 std"可沿用**,需二选一(见下)。
+
+**方案 E-1(推荐,规范双模式)**:照 **`S6_REPORT` 模式**为每个入站对象新建 `mdp_stg_sX_xxx` + `mdp_std_sX_xxx` + transform,`mdp_entity` 登记:
+- `source_code='MES_WMS_SQLSERVER'`、`source_table_name='dbo.xxx'`、`target_table_name='mdp_stg_sX_xxx'`、`incr_column`(如 `update_time`)、`batch_size`、`sync_window_type`。
+- 参考实现:`Manufacturing/ReportWorkMdpSyncService.cs`(含 `EnsureTablesAsync` 建 stg/std、`TransformStandardAsync`)与其 SQL(`UpdateScripts/1.0.264.sql` 里 `mdp_stg_s6_report/mdp_std_s6_report/S6_REPORT` 实体)。
+
+**方案 E-2(最小改动,过渡)**:入站数据直接进现有 `dwd_t8_*` / KPI 计算链,不建 stg/std。**代价**:失去双模式契约一致校验(DB vs API 等价),仅建议临时打通用。
+
+**验收点**:
+- E-1:跑一次入站 → 对应 `mdp_stg_sX_*`/`mdp_std_sX_*` 有数据;`mdp_entity.last_cursor` 推进;契约脚本可比对。
+- E-2:`dwd_t8_*` 有数据、KPI 可算;文档标注为过渡态、后续补 stg/std。
+
+---
+
+### 任务 F:回写动作映射登记(action → 表/键/列)
+**交付物**:在任务书 §5 权威动作表基础上,补一张「DB 回写映射」小表(可用配置常量或 `mdp_sync_task_formula`/`mdp_sync_task_param` 承载):
+
+| action_code | 目标源 | 接口表 | 键 | 写入列 |
+|-------------|--------|--------|----|--------|
+| `S5_IQC_RESULT_PUSH` | `MES_WMS_SQLSERVER` | `dop_if.dop_iqc_result` | `bill_no` | `pd,hgsl,bhgsl,result_time,src_system` |
+| `S6_REPORT_PUSH` | `MES_WMS_SQLSERVER` | `dop_if.dop_report_result` | `report_no` | `qty_ok,qty_ng,report_time,src_system` |
+| `S7_FQC_RESULT_PUSH` | `MES_WMS_SQLSERVER` | `dop_if.dop_fqc_result` | `bill_no` | `pd,hgsl,bhgsl,result_time,src_system` |
+
+**验收点**:每个 action 的接口表/键/列有唯一权威定义,供 enqueue 侧构造 payload。
+
+---
+
+### 任务 G:各 Sx 动作服务改为 enqueue DB 型 payload
+**交付物**:改 `MaterialWarehouse/IqcInspBillFlowService.cs`、`Manufacturing/IpqcInspectionFlowService.cs`(+报工服务)、`FinishedWarehouse/FqcInspBillFlowService.cs`
+
+**改动**:把现有写 `mdp_outbox` 的地方,`TargetSourceCode` 指向 DB 源(如 `MES_WMS_SQLSERVER`),`PayloadJson` 用 **DB 型**约定(§3)。保留现有幂等 `SELECT COUNT(1) ... WHERE target_source_code=... AND action_code=... AND idem_key=...` 去重。
+
+> 若同时保留 API 通道,可用**双源**:一条写 API 源、一条写 DB 源(不同 `target_source_code`),Job 各自分发;但注意 §2.1 双写冲突约束——同一业务字段只允许一个「写入真源」。默认本场景**只走 DB 源**。
+
+**参考现有 enqueue(`FqcInspBillFlowService.cs` L416–451)**,把 `path/method/body` 换成 `target/table/keys/set/mode`。
+
+**验收点**:触发一次 FQC 合格 → `mdp_outbox` 出现 DB 型待推 → 60s 内 `MdpDbPushExecutor` 写入 SQLServer 接口表 → 对方可查到。
+
+---
+
+### 任务 H:契约/UAT 验收脚本
+**交付物**:`doc/db/mdp/contract_tests/run_writeback_s5_s7_db.py`(可重复跑)
+
+**步骤**:清接口表测试键 → 触发一次 Sx 合格闭环(或直接插一条 DB 型 outbox)→ 等 Job 或手动调执行器 → 断言 SQLServer 接口表出现对应行且字段正确 → 重复触发同键断言不新增行(幂等)。
+
+**验收点**:断言全绿;`mdp_outbox.status=1`。
+
+---
+
+### 任务 I:手动触发/联调步骤(UAT 必备)
+**交付物**:联调手册片段(可并入 UAT 指南)。
+
+- **手动跑一次入站**(DB 源抽数):调用同步任务/调度入口按实体触发(参考现有同步任务配置页 `Web/src/views/aidop/data-platform/syncTasks.vue` 或后端 `MdpSourcePullDispatcher.PullByEntityCodeAsync(entityCode, ctx)`);确认 `mdp_stg_*` 有行、`last_cursor` 推进。
+- **手动逼一次回写**:
+  1. 直接插一条 DB 型 `mdp_outbox`(`status=0`、`target_source_code='MES_WMS_SQLSERVER'`、DB 型 payload),等 `MdpOutboxPushJob`(60s)自动扫;或
+  2. 临时把 `PeriodSeconds` 调小 / 手动调用 `MdpDbPushExecutor.PushPendingAsync(list)` 联调。
+- **查看结果**:对方 SQLServer 接口表出现/更新行;`mdp_outbox.status=1`、`response_json` 含 `affected`;失败看 `error_msg` 与 `retry_count`。
+- **回执核对**:可用 MCP `user-sqlserver-dopdemorq`(或对应 SQLServer 连接)`SELECT` 接口表验证。
+
+**验收点**:入站、回写两个方向均可在无真实业务触发的情况下手动跑通并观测到结果。
+
+---
+
+## 5. 验收标准(Definition of Done)
+1. `MdpDbPushExecutor` 与 Job 分发落地,`dotnet build` 通过。
+2. `mdp_source` 有 SQLServer 源且健康检查通过。
+3. S5/S6/S7 各至少一个回写 action 端到端写入 SQLServer 接口表成功,幂等可重复。
+4. 入站方向 `mdp_stg/std` 正常(沿用现成能力)。
+5. 接口表清单、方向、owner、键、列映射全部落档(§4-D/F)。
+6. 契约脚本 §4-H 通过。
+7. 未破坏现有 API 回写路径(回归 `S1/S2/S4/S5 API` 通道仍可用)。
+
+---
+
+## 6. 风险与回滚
+- **跨库无事务**:单条 outbox 写失败自动重试 3 次转失败态,可人工补推;不影响业务闭环(enqueue 包 try/catch)。
+- **双写冲突**:严格执行 §2.1 表/列所有权拆分;接口表 owner=DOP 的表 MES/WMS 只读。
+- **SQLServer 连接/权限**:DOP 账号在对方库需两级权限——**建接口表要 DDL**(`CREATE TABLE`/`CREATE SCHEMA dop_if`,若由 DOP 维护结构),**回写要 DML**(接口表 `INSERT/UPDATE`)、入站要目标表 `SELECT`。若对方不给 DDL,则接口表 DDL 由对方 DBA 按 DOP 提供的脚本执行。连接/权限失败由源健康检查提前暴露。
+- **回滚**:Job 分发可通过配置只跑 API 执行器;DB 型 outbox 可暂停(不影响 API 通道)。DDL 变更在对方库,回滚即 drop 接口表并让对方 repoint 回原连接。
+
+---
+
+## 7. 执行顺序建议
+A(执行器)→ B(Job 分发)→ 本地编译验证 → C(源登记)→ D(SQLServer DDL)→ E(入站实体)→ F(映射登记)→ G(enqueue 改造)→ H(契约验收)。
+
+> 若 UAT 时间紧:可先 A+B+C+一个 action(如 S7)打通端到端,再补 S5/S6 与入站。
+
+---
+
+## 8. 版本号提醒(提交时)
+- 本次落地涉及**后端代码**(新增执行器、改 Job、改动作服务):提交时须递增 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>/<AssemblyVersion>/<FileVersion>`(三处同号 patch +1)。
+- 若无前端改动,**不**递增 `Web/package.json`。
+- 纯本方案文档提交(仅本 md)**不升版本号**。
+
+---
+
+## 9. 本期不做(out-of-scope,明确边界)
+- **回写撤销/冲销**:当前只设计 `upsert`(新增/更新),**不含**删除或反写语义(如检验结果撤回、报工作废)。若业务需要,另立 `action_code`(如 `S7_FQC_RESULT_VOID`)并在 payload `mode` 扩展 `delete`/写冲销列,属后续增量,不在本任务书。
+- **多目标广播**:一个业务事件同时回写多个第三方系统(MES 与 WMS 分属不同 SQLServer 源)时,需 enqueue 多条 outbox(不同 `target_source_code`);本期按单目标示例,多目标照此复制即可。
+- **实时/低延迟回写**:本方案是 60s 轮询的最终一致,不承诺秒级实时;如需实时,另设计触发式推送,不在本期。
+- **对方 SQLServer 的历史数据迁移/清洗**:仅对接增量与回写,历史数据搬迁不在本期。
+- **接口表结构版本管理**:接口表 DDL 变更的版本化/双方联调流程按运维约定,不在本任务书代码范围。