|
|
@@ -0,0 +1,787 @@
|
|
|
+# WP10 · 跨库成败判定与失败兜底
|
|
|
+
|
|
|
+| 项 | 内容 |
|
|
|
+|----|------|
|
|
|
+| 目标 | 在**无分布式事务**前提下,让跨库业务动作的「成功 / 部分成功 / 失败」判定准确、提示可读、失败可自愈,且任何单次失败都不得让业务对象进入**不可重试的卡死态** |
|
|
|
+| 编写日期 | 2026-08-09 |
|
|
|
+| 状态 | **D10/D11 已定(2026-08-09),S0/S1/S2/S3/S4a 全部可派发**;仅 S4b 受 D12 阻断。§7 盘点已完成,在联调环境实测到 F13 的活例(工单 `M500000005` 卡死)与 4 条无人知晓的死信。165(`123.60.180.165`)当前**是开发测试服务器**,故这些是**缺陷实证而非生产事故**,存量数据可直接清理;但缺陷本身必须修,因为同一份代码将来要跑真实 MES/WMS 库 |
|
|
|
+| 前置 | WP2(165 取号)、WP3(Outbox)、WP4(建领料)已落地 |
|
|
|
+| 产出 | 单据级返回契约 + 错误码表 + 事务边界修正 + Outbox 退避与死信管理 + 孤儿单盘点脚本 |
|
|
|
+| 不做 | 不引入分布式事务 / XA / MSDTC;不改 165 表结构与触发器;不改 WP9 的权威库决策(D7 另议);本包**不做**自动 DELETE 165 数据(见 D10) |
|
|
|
+| **开工前必读** | [`00-总体方案.md`](./00-总体方案.md)、[`00B-执行须知(模型输入契约).md`](./00B-执行须知(模型输入契约).md)、[`WP3-回写执行器.md`](./WP3-回写执行器.md)、[`WP9-领料建单双路径统一.md`](./WP9-领料建单双路径统一.md) |
|
|
|
+
|
|
|
+> **本工作包的由来**:总体方案 §1 已把「不使用分布式事务」定为硬约束,替代方案是「本地事务 + Outbox + 幂等 + 对账」。WP4 落地后复查发现,这套替代方案的**三个前提都没有完全成立**:本地事务边界缺失、幂等键失效、失败退避缺失。本包负责把替代方案补齐到可交付状态。
|
|
|
+>
|
|
|
+> **与 WP6 的分工**:WP6 管「事后对账发现差异」,WP10 管「事中判定与自愈」。WP10 的死信清单是 WP6 对账的输入之一。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. 为什么不能用分布式事务(决策依据,勿再翻案)
|
|
|
+
|
|
|
+执行者可能会提出「加个 `TransactionScope` 就解决了」。以下是不可行的实证理由,写在这里避免重复讨论:
|
|
|
+
|
|
|
+| # | 理由 | 说明 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | **技术上跨不过去** | MySQL 的 .NET 驱动(MySqlConnector / MySql.Data)不作为 MSDTC 资源管理器参与事务提升,`TransactionScope` 跨到第二个连接即抛异常。.NET 的分布式事务支持本身也仅限 Windows + MSDTC 可协调的资源 |
|
|
|
+| 2 | **需要改 165 环境** | MSDTC 要在 165 服务器上启用分布式协调、开放 RPC 端口、配置网络 DTC 权限。违反红线 1「MES/WMS 不可做任何修改」,且我们只有 `db_owner` |
|
|
|
+| 3 | **会拖死第三方系统** | `NbrDayInfo` 计数器行与 WMS APP **共用**(WP2),靠行锁互斥、毫秒级持有。2PC 会把这把锁从 prepare 持有到 commit,跨一次网络往返。我方抖动 = 车间 APP 集体取号阻塞 |
|
|
|
+| 4 | **协调者故障留 in-doubt** | 165 上出现 in-doubt 事务需客户 DBA 手工处置,锁不释放。把我方可用性问题转嫁成客户停产事故 |
|
|
|
+| 5 | **保不住真正的一致性** | WMS APP 自身的写入不在我方事务内。实际冲突是「计划侧改字段 vs 执行侧改字段」,由字段共管矩阵 + 乐观 `expect` 解决,与事务无关 |
|
|
|
+
|
|
|
+**结论**:目标不是强一致,而是**最终一致 + 不丢不重 + 失败可见可自愈**。本包围绕这三点展开。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 现状缺陷清单(实测,含证据行号)
|
|
|
+
|
|
|
+代码基线:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs`(后端 1.0.301)
|
|
|
+
|
|
|
+### 2.1 严重(会造成数据卡死或静默错误)
|
|
|
+
|
|
|
+| # | 缺陷 | 证据 | 后果 |
|
|
|
+|---|------|------|------|
|
|
|
+| **F1** | **165 主从插入无本地事务** | `InsertNbrOn165Async` :387–476,逐条 `ss.Ado.ExecuteCommandAsync`,全文无 `UseTran`/`BeginTran` | 主表插入成功、明细中途失败 → 165 留下**有头无体的孤儿单** |
|
|
|
+| **F2** | **孤儿单导致工单永久卡死** | 幂等判定 :75–82 只查 `SELECT DISTINCT WorkOrd FROM NbrMaster WHERE Domain=@d AND Type=@t` | F1 产生孤儿后,重试时该工单被判为「已建单」而跳过,**再也建不出正确的领料单**,只能人工介入 165 |
|
|
|
+| **F3** | **部分成功被报成全部成功** | :79–82 静默 `Where` 过滤后不记录被剔除项;:215–223 返回体无跳过信息 | 勾 5 张、2 张已建过 → 只建 3 张、返回 `ok=true`,用户不知道有 2 张未处理 |
|
|
|
+| **F4** | **业务写与 Outbox 入队不在同一本地事务** | 本库 `WorkOrdMaster` 更新 :150–169、`WorkOrdRouting` 更新 :165–169、Outbox 入队 :177–198,三段各自独立提交(同为 MySQL) | 中间抛异常 → 本库已置 `r`、165 永远收不到回写。**Outbox 模式的原子前提不成立** |
|
|
|
+| **F13** | **幂等命中时整单跳过,不补做未完成的跨库副作用** | :80–82 命中即 `Where` 剔除,后续工单头 UPSERT、里程碑工序更新全部不执行 | 「领料单已建成、但工单头回写失败」的工单,重放请求会被判为「已建单」而整单跳过,**缺失的回写永远补不上**。已在 §7 盘点中实测到活例(`M500000005`) |
|
|
|
+
|
|
|
+### 2.2 中等(可靠性与可运维性)
|
|
|
+
|
|
|
+| # | 缺陷 | 证据 | 后果 |
|
|
|
+|---|------|------|------|
|
|
|
+| **F5** | **Outbox 幂等键含随机 Guid,去重实际失效** | :172 `trace = $"pick\|{domain}\|{now:yyyyMMddHHmmss}\|{Guid.NewGuid():N}"`,:189 `idem = $"{trace}\|wom\|{wo}"` | 每次调用键都不同,`MdpOutboxEnqueueService.TryEnqueueAsync` :26–29 的去重永不命中,重复消息堆积(UPSERT 本身幂等故不出错,但污染队列与监控) |
|
|
|
+| **F6** | **重试无退避,165 抖动 3 分钟即烧成死信** | `MdpTargetPushDispatcher.MaxRetry = 3` :12;`MdpOutboxPushJob` `[PeriodSeconds(60), RunOnStart=true]`;`MdpOutbox` 实体**无 `next_retry_time` 列** | 165 例行维护半小时 → 期间所有回写在约 3 分钟内耗尽重试,全部 `status=2` |
|
|
|
+| **F7** | **死信无告警、无列表、无手工重推** | 全仓 `.vue` 中检索 `mdp_outbox` 无结果;`MdpTargetPushDispatcher.MarkAsync` :106 仅写库 | `status=2` 只能 DBA 查表看 `error_msg`,运维不可达 |
|
|
|
+| **F8** | **165 `WorkOrdRouting` 直写不走 Outbox** | :202–209 裸 `ExecuteCommandAsync`,失败即抛 | 此时 Nbr 已建、本库已改,该更新丢失且无任何重试 |
|
|
|
+
|
|
|
+### 2.3 提示友善性
|
|
|
+
|
|
|
+| # | 缺陷 | 证据 | 后果 |
|
|
|
+|---|------|------|------|
|
|
|
+| **F9** | **`ok=false` 混用三种语义,一律红字报错** | :72「没有需要下达的工单」(空集)、:82「包含已生成领料单」(幂等命中,实为成功)、:89「工单无物料明细」(数据缺失) | 重复点击弹红错,用户以为失败又点一次 |
|
|
|
+| **F10** | **异常路径给不出人话** | :124 `throw Oops.Oh("取号不足…")`、:430 `throw new InvalidOperationException(…)`;控制器为 `[NonUnify]` :23 | 前端 `e?.message` 大概率只拿到 `Request failed with status code 500` |
|
|
|
+| **F11** | **`ok=true` 名不副实** | :215–223 返回时 165 工单头回写仅**入队**未完成 | 用户看到「已生成」但 165 状态未变,产生疑问工单 |
|
|
|
+| **F12** | **批量结果挤在单行 `ElMessage`** | `workOrderDispatchList.vue` :325–326 `nbrs.map(...).join(';')` | 20 张单时提示条爆屏,无法阅读 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 决策项
|
|
|
+
|
|
|
+| # | 决策 | 选项 | 结论 |
|
|
|
+|---|------|------|------|
|
|
|
+| **D10** | 165 上**存量孤儿单**如何处置 | A:**改幂等判定口径**(`NbrMaster` 存在**且有明细**才算已建单),孤儿不阻塞重建<br>B:检测到孤儿即自动 `DELETE` 后重建<br>C:把孤儿 `NbrMaster.Status` 软标记为作废 | ✅ **已定 A**(2026-08-09)。§7 实测孤儿 0 条;存量清理走 S0,代码不内置 DELETE |
|
|
|
+| **D11** | 批量建单时 **165 不可达**的语义 | A:**整批 fail-fast**,一张不建,本地事务全回滚<br>B:逐单尽力而为,返回部分成功<br>C:转为异步任务,全部走 Outbox 补偿 | ✅ **已定 A**(2026-08-09)。依据见下 |
|
|
|
+| **D12** | Outbox 退避参数与死信告警渠道 | 退避序列 / `MaxRetry` 取值 / 告警走日志、站内信还是钉钉 | ⏳ **待定**。建议:退避 `1m,5m,15m,30m,60m,120m`;`MaxRetry=6`;先落**日志 + 站内信**。仅阻断 S4b |
|
|
|
+
|
|
|
+### D11 定 A 的依据
|
|
|
+
|
|
|
+**「工单已下达但 165 没有领料单」比「什么都没做」更难收拾。** 前者是跨库半成品状态,需要人工比对两库才能判断该补还是该退;后者用户重试一次即可。
|
|
|
+
|
|
|
+选 A 之后的具体行为:165 取号或建单抛出 `MES_UNREACHABLE` 时,**MySQL 本地事务根本不开启**,本库工单状态保持原值,`mdp_outbox` 无新增行,返回 `code=FAILED` 且 `summary.created=0`。用户看到的是「165 不可达,稍后重试」,重试时一切从头,无残留。
|
|
|
+
|
|
|
+**不选 B 的理由**:逐单尽力而为会在 165 抖动时产生一批「建了一半」的工单,每一张都要单独判断状态。§7.1 已经演示过收拾一张这种工单要查几个库。
|
|
|
+
|
|
|
+**不选 C 的理由**:领料单号必须从 165 的共用计数器取(WP2),无法离线预分配;转异步只是把失败推迟到用户看不见的地方。
|
|
|
+
|
|
|
+### D10 建议 A 的依据(**执行者重点阅读**)
|
|
|
+
|
|
|
+**先区分两件不同的事,不要混谈:**
|
|
|
+
|
|
|
+| | 存量数据清理(**现在,在 `123.60.180.165` 这台开发测试机上**) | 代码内置的运行时行为(**将来跑真实 MES/WMS 库**) |
|
|
|
+|---|---|---|
|
|
|
+| 能不能 `DELETE` | **能**。该机是开发测试服务器,现有数据全为冒烟测试产物,随便清 | **不能**。同一份代码会部署到生产,届时红线 1 全部生效 |
|
|
|
+| 怎么做 | 手工 SQL 清理,见 §7.2 | 按 D10-A:只改判定口径,不删任何行 |
|
|
|
+
|
|
|
+**代码不内置自动 `DELETE` 的理由**(针对将来的生产环境):
|
|
|
+
|
|
|
+1. **判定不可靠**:「主表有、明细无」在并发下可能是他方事务的中间态。虽然已确认**不会出现旧 DOP 与 Ai-DOP 并存**,但 **WMS APP 仍是 165 上的并发写入方**(WP2 的计数器就是与它共用),且我方自身也可能多实例并发。
|
|
|
+2. **引用未穷举**:`RecID` 是 identity,`MobileTask`、`QadTracking`、库存事务表均可能已引用;APP 也可能已把单号打印下发。我方只查了 `NbrDetail`。
|
|
|
+3. **触发器未知**:生产 MES/WMS 库上可能挂 DELETE 触发器(审计、库存占用回滚)。第三方库,不知道也不该假设。
|
|
|
+4. **不可回滚**:生产环境我方无备份权限、无审计。误删无法举证与恢复;与红线 1 冲突。
|
|
|
+5. **号段空洞**:`NbrDayInfo` 不回退;若客户对单号连续性有审计要求即成问题。
|
|
|
+
|
|
|
+**A 方案的关键洞见**:卡死的根因**不是孤儿单存在,而是幂等判定把它当成了「已建单」**。把判定口径改成「有主单**且**有明细」,孤儿即不再阻塞——重建会用新号生成一张完整单,孤儿留在库里当垃圾。有头无体的单 APP 扫不出可作业内容,危害极低。配合 S1 的事务化,新孤儿基本不再产生。
|
|
|
+
|
|
|
+> **注意**:A 方案本身只解决「孤儿阻塞」这一种卡死。§7.1 实测到的 `M500000005` 属另一种(单据完整但后续副作用缺失),必须靠 S1 步骤 3b 的「重放收敛到终态」解决。两者都要做。
|
|
|
+
|
|
|
+> **回填状态**:✅ 已在 [`WP6-对账与运维.md`](./WP6-对账与运维.md) §2.1/§2.4/§4 登记孤儿单、Outbox 死信与跨库半成品三类对账对象,并统一了重放端点;✅ 已在 [`附-字段共管矩阵.md`](./附-字段共管矩阵.md) §13.1 登记建领料的回写通道变更。⏳ 仅剩 D12 定案后回填本节。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. 目标形态
|
|
|
+
|
|
|
+### 4.1 三条不变量
|
|
|
+
|
|
|
+1. **无卡死**:任何单次失败之后,重放同一请求都必须能推进到正确终态;不存在「必须人工改 165 才能继续」的状态。
|
|
|
+2. **判定诚实**:返回体必须逐单交代结果;「部分成功」不得表述为「成功」;「幂等命中」不得表述为「失败」。
|
|
|
+3. **写原子**:同一物理库内的多次写要么全成要么全不成;跨库写必须由 Outbox 承载并可重试至成功或明确死信。
|
|
|
+
|
|
|
+### 4.2 统一返回契约(本包定义,后续跨库动作一律照此)
|
|
|
+
|
|
|
+控制器为 `[NonUnify]`,**业务失败一律 HTTP 200 + 结构化 body,不得抛异常**;仅未预期异常才允许 500。
|
|
|
+
|
|
|
+```jsonc
|
|
|
+{
|
|
|
+ "ok": true, // = summary.failed == 0 && summary.created + summary.existed > 0
|
|
|
+ "code": "OK", // OK | PARTIAL | NOOP | FAILED
|
|
|
+ "summary": { "requested": 5, "created": 3, "existed": 1, "skipped": 1, "failed": 0 },
|
|
|
+ "items": [
|
|
|
+ { "workOrd": "WO001", "result": "created", "nbr": "SM20260809001", "detailCount": 12 },
|
|
|
+ { "workOrd": "WO002", "result": "existed", "nbr": "SM20260808007", "reasonCode": "EXISTS",
|
|
|
+ "reason": "165 上已有有效领料单" },
|
|
|
+ { "workOrd": "WO003", "result": "skipped", "reasonCode": "NO_DETAIL",
|
|
|
+ "reason": "工单无物料明细", "hint": "请先在工单明细中维护物料后重试" },
|
|
|
+ { "workOrd": "WO004", "result": "failed", "reasonCode": "NO_KEEPER",
|
|
|
+ "reason": "物料 M001 未解析到保管员", "hint": "见 WP7 §3,补齐 EmpWorkDutyMaster" }
|
|
|
+ ],
|
|
|
+ "writeback": { "enqueued": 3, "state": "pending" }, // 165 工单头回写为异步
|
|
|
+ "trace": "pick|8010|20260809133000|ab12…"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`code` 判定规则:
|
|
|
+
|
|
|
+| code | 条件 | 前端呈现 |
|
|
|
+|------|------|---------|
|
|
|
+| `OK` | `failed=0 && skipped=0 && created>0` | 绿色成功 |
|
|
|
+| `PARTIAL` | `created>0` 且(`failed>0` 或 `skipped>0`) | 橙色警告 + 明细弹窗 |
|
|
|
+| `NOOP` | `created=0 && failed=0`(全部 `existed`/`skipped`) | 蓝色信息,**不报错** |
|
|
|
+| `FAILED` | `created=0 && failed>0` | 红色错误 + 明细弹窗 |
|
|
|
+
|
|
|
+### 4.3 错误码表(`reasonCode`)
|
|
|
+
|
|
|
+| reasonCode | 含义 | 归类 | 用户可自行处理 | hint 指向 |
|
|
|
+|------------|------|------|---------------|-----------|
|
|
|
+| `EXISTS` | 已有有效领料单 | existed | — | 展示原单号 |
|
|
|
+| `NO_DETAIL` | 工单无物料明细 | skipped | 是 | 补工单明细 |
|
|
|
+| `WO_STATE_INVALID` | 工单状态不允许下达(`c`/`w`) | skipped | 是 | 检查工单状态 |
|
|
|
+| `ITEM_INVALID` | 物料主数据缺失 | skipped | 是 | 补 `ItemMaster` |
|
|
|
+| `NO_KEEPER` | 保管员解析失败 | failed | 否 | WP7 §3 补 `EmpWorkDutyMaster`(**须先实现 WP7 §3.2 中断校验,见 §11 第 7 项**) |
|
|
|
+| `KEEPER_RULE_UNSAFE` | 物料号或职责区间含 `[0-9A-Z]` 之外的字符,区间匹配口径不可靠 | failed | 否 | 人工指派保管员;成因见 WP7 §3.6 |
|
|
|
+| `ORPHAN_DETECTED` | 检出孤儿单,已用新号重建 | created | — | 附孤儿单号供 DBA 清理 |
|
|
|
+| `SEQ_FAILED` | 165 取号失败 | failed | 否 | 联系运维查 `NbrDayInfo` |
|
|
|
+| `MES_UNREACHABLE` | 165 不可达(连接/超时/认证) | failed | 否 | 联系运维;稍后重试 |
|
|
|
+| `MES_WRITE_FAILED` | 165 写入被拒(权限/约束/类型) | failed | 否 | 附 SQL 错误号,联系运维 |
|
|
|
+
|
|
|
+> `MES_UNREACHABLE` 与 `MES_WRITE_FAILED` 必须分开:前者稍后重试即可,后者重试无用需人介入。判据用 `SqlException.Number` + 异常类型,见 S2 步骤 3。
|
|
|
+
|
|
|
+### 4.4 目标事务边界
|
|
|
+
|
|
|
+```text
|
|
|
+POST /api/aidop/wms-pick/create
|
|
|
+ │
|
|
|
+ ├─【只读】加载候选 + 幂等探查(含孤儿检出) ← 无写入,失败可直接返回
|
|
|
+ │
|
|
|
+ ├─【165 取号】NbrSequenceService.AllocateAsync ← 独立短事务(WP2,行锁毫秒级)
|
|
|
+ │
|
|
|
+ ├─【165 本地事务】NbrMaster + NbrDetail 全部插入 ← 新增:ss.Ado.UseTran
|
|
|
+ │ 失败 → 整体回滚,165 上不留任何痕迹,返回 FAILED
|
|
|
+ │
|
|
|
+ ├─【MySQL 本地事务】WorkOrdMaster + WorkOrdRouting ← 新增:_db.Ado.UseTran
|
|
|
+ │ + mdp_outbox 入队(工单头 UPSERT)
|
|
|
+ │ + mdp_outbox 入队(里程碑工序 UPDATE,F8 改造)
|
|
|
+ │ 失败 → 整体回滚;165 上已建的单由下次幂等命中返回原号(不重复建)
|
|
|
+ │
|
|
|
+ └── 返回 §4.2 契约;165 回写由 Outbox 异步保证最终一致
|
|
|
+```
|
|
|
+
|
|
|
+**残留窗口(可接受,须在验收中确认行为)**:165 建单成功、MySQL 事务失败。此时 165 有单、本库工单仍为 `p`。重放请求时幂等命中返回原号并补做本库更新,不重复建单。**这是不用分布式事务所付的全部代价**,代价上限为「一次重放」。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. 实施步骤(5 步,每步独立可交付、可验收)
|
|
|
+
|
|
|
+> 每步单独提交,按 [`version-bump-on-commit`](../../../../.cursor/rules/version-bump-on-commit.mdc) 递增对应端版本号。
|
|
|
+
|
|
|
+**建议排期**(目标为「完成开发并能进行测试」):
|
|
|
+
|
|
|
+| 优先级 | 步骤 | 理由 |
|
|
|
+|--------|------|------|
|
|
|
+| **P0 · 先做** | **S0**(环境复位,纯 SQL 10 分钟) | 清掉测试残留,让 `M500000005` 可作为后续全部验收的统一用例 |
|
|
|
+| **P0 · 测试前置** | **S1**(事务 + F13 重放收敛) | 不修则每个失败过的测试工单永久不可复用,联调要反复手工清库 |
|
|
|
+| **P0 · 测试前置** | **S4a**(Outbox 监控页 + 手工重推) | 不修则回写失败无从察觉,链路不通只能查库定位 |
|
|
|
+| **P1 · 联调提效** | **S2 + S3**(返回契约 + 前端呈现) | 失败原因可读,联调不用靠猜;两步必须同版本发布 |
|
|
|
+| **P2 · 上线前** | **S4b**(退避 + 告警) | 防的是目标库长时间不可用,开发阶段收益有限 |
|
|
|
+
|
|
|
+### S0 · 环境复位(**先做,10 分钟,纯 SQL**)
|
|
|
+
|
|
|
+165 为开发测试机,存量直接清理。**执行前先把 §7.0/§7.1 的现状截图留档**(缺陷证据)。
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 【165 dopdemorq】清掉冒烟测试遗留的领料单(先明细后主表)
|
|
|
+DELETE d FROM NbrDetail d
|
|
|
+ JOIN NbrMaster m ON d.NbrRecID = m.RecID
|
|
|
+ WHERE m.Type = 'SM' AND m.Nbr = 'SM20260809003';
|
|
|
+DELETE FROM NbrMaster WHERE Type = 'SM' AND Nbr = 'SM20260809003';
|
|
|
+-- 复位取号计数器,让重跑从 001 开始(可选)
|
|
|
+-- UPDATE NbrDayInfo SET NextValue = 1 WHERE Domain='8010' AND NbrType='sm';
|
|
|
+```
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 【本库 aidopdev】清空 Outbox(8 行全为测试产物,其中 4 条死信)
|
|
|
+DELETE FROM mdp_outbox;
|
|
|
+
|
|
|
+-- 工单复位为待下达
|
|
|
+UPDATE WorkOrdMaster SET Status='p', Batch='' WHERE WorkOrd='M500000005';
|
|
|
+UPDATE WorkOrdRouting SET Status='p' WHERE WorkOrd='M500000005';
|
|
|
+```
|
|
|
+
|
|
|
+**验收**:三处均已复位;`M500000005` 可作为后续 S1 的全链路回归用例重新走一遍。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### S1 · 止血:事务边界与重放收敛(依赖 D10-A,已定)
|
|
|
+
|
|
|
+**范围**:只动 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs`,**不碰**公共 Outbox 设施(`MdpTargetPushDispatcher` / `MdpOutboxPushWorker` / `MdpOutbox` 实体)。
|
|
|
+
|
|
|
+#### 1) 165 主从插入包本地事务(修 F1)
|
|
|
+
|
|
|
+`ss` 是 `MdpSourceScopeFactory` 返回的**独立 SqlSugar 客户端**(非默认库),用 `Ado.BeginTranAsync/CommitTranAsync/RollbackTranAsync`,与本仓其它写法一致(参照 `MdpStdFullReplace.cs` :51)。
|
|
|
+
|
|
|
+```csharp
|
|
|
+private static async Task InsertNbrOn165Async(
|
|
|
+ ISqlSugarClient ss, List<DraftNbrMaster> masters, List<DraftNbrDetail> details, CancellationToken ct)
|
|
|
+{
|
|
|
+ await ss.Ado.BeginTranAsync();
|
|
|
+ try
|
|
|
+ {
|
|
|
+ foreach (var m in masters)
|
|
|
+ {
|
|
|
+ // …现有 INSERT NbrMaster … OUTPUT INSERTED.RecID(保持不变)
|
|
|
+ // …现有 foreach 明细 INSERT NbrDetail(保持不变)
|
|
|
+ }
|
|
|
+ await ss.Ado.CommitTranAsync();
|
|
|
+ }
|
|
|
+ catch
|
|
|
+ {
|
|
|
+ await ss.Ado.RollbackTranAsync();
|
|
|
+ throw; // 原样上抛,由调用方 ClassifyMesError 分类(S2)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **禁止**在此处 `catch` 后吞异常或改写为业务返回值——分类归 S2 统一做,S1 只保证原子性。
|
|
|
+
|
|
|
+#### 2) 幂等探查改三分类(修 F2 + F13)
|
|
|
+
|
|
|
+把原来 :75–82 的「一条 SQL + `Where` 过滤」替换为**一次查询得三个集合**:
|
|
|
+
|
|
|
+```csharp
|
|
|
+// 返回:workOrd -> (nbr, hasDetail)
|
|
|
+private async Task<Dictionary<string, (string Nbr, bool HasDetail)>> ProbeExistingAsync(
|
|
|
+ ISqlSugarClient ss, string domain, List<string> workOrds, CancellationToken ct)
|
|
|
+{
|
|
|
+ var inSql = string.Join(",", workOrds.Select((_, k) => "@w" + k));
|
|
|
+ var pars = workOrds.Select((w, k) => new SugarParameter("@w" + k, w)).ToList();
|
|
|
+ pars.Add(new SugarParameter("@d", domain));
|
|
|
+ pars.Add(new SugarParameter("@t", NbrTypeBill));
|
|
|
+
|
|
|
+ var rows = await ss.Ado.SqlQueryAsync<ProbeRow>($@"
|
|
|
+ SELECT m.WorkOrd, m.Nbr,
|
|
|
+ CASE WHEN EXISTS (SELECT 1 FROM NbrDetail d WHERE d.NbrRecID = m.RecID)
|
|
|
+ THEN 1 ELSE 0 END AS HasDetail
|
|
|
+ FROM NbrMaster m
|
|
|
+ WHERE m.Domain = @d AND m.Type = @t
|
|
|
+ AND ISNULL(m.IsActive, 1) = 1
|
|
|
+ AND m.WorkOrd IN ({inSql})", pars.ToArray());
|
|
|
+ // 同一工单多行时取「有明细」的那条优先
|
|
|
+ …
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+三个分支的处理:
|
|
|
+
|
|
|
+| 探查结果 | 判定 | 动作 |
|
|
|
+|---------|------|------|
|
|
|
+| 无记录 | 新建 | 正常走取号 + 建单 |
|
|
|
+| 有主单**且有明细** | `existed` | **不建单**,但**必须走「补做」**(见下 3 条) |
|
|
|
+| 有主单**无明细**(孤儿) | 重建 | 允许用新号重建;`items[].reasonCode = ORPHAN_DETECTED`,附孤儿单号。**不删孤儿**(D10-A) |
|
|
|
+
|
|
|
+#### 3) 幂等命中时的「补做」(修 F13,**本步核心**)
|
|
|
+
|
|
|
+**语义变更**:本接口的契约从「建一张领料单」改为「**把工单推进到已下达终态**」。重放必须**收敛到终态**,而不是发现已建单就直接返回。
|
|
|
+
|
|
|
+补做的目标态(对 `existed` 的工单逐个校验、缺什么补什么):
|
|
|
+
|
|
|
+| # | 目标 | 检查方式 | 不满足时 |
|
|
|
+|---|------|---------|---------|
|
|
|
+| a | 165 `WorkOrdMaster` 存在且 `Status='r'`、`Batch` 正确 | 查 165 | 入队工单头 UPSERT |
|
|
|
+| b | 165 里程碑工序 `Status='r'` | 查 165 `WorkOrdRouting` `MilestoneOp=1 AND Status<>'C'` | 逐工序入队 UPDATE |
|
|
|
+| c | 本库 `WorkOrdMaster.Status='r'`、`Batch` 正确 | 查本库 | 直接 UPDATE |
|
|
|
+| d | 本库里程碑工序 `Status='r'` | 查本库 | 直接 UPDATE |
|
|
|
+
|
|
|
+因 idem_key 已改为业务键(下条),**重复入队天然幂等**,无需先查 `mdp_outbox`;`TryEnqueueAsync` 返回 `false` 即表示已有在途/成功消息,计入 `writeback.skipped` 即可。
|
|
|
+
|
|
|
+返回 `result = "existed"` + 原 `nbr`,并在 `writeback` 里带上本次补做条数,例如 `{"enqueued":1,"state":"pending","reconciled":true}`。
|
|
|
+
|
|
|
+#### 4) 本库写 + 入队合并为一个 MySQL 事务(修 F4)
|
|
|
+
|
|
|
+```csharp
|
|
|
+await _db.Ado.BeginTranAsync();
|
|
|
+var pendingPulse = false;
|
|
|
+try
|
|
|
+{
|
|
|
+ // WorkOrdMaster UPDATE(现 :150–163)
|
|
|
+ // WorkOrdRouting UPDATE(现 :165–169)
|
|
|
+ // Outbox 入队:工单头 UPSERT + 逐工序 UPDATE
|
|
|
+ await _db.Ado.CommitTranAsync();
|
|
|
+ pendingPulse = true;
|
|
|
+}
|
|
|
+catch
|
|
|
+{
|
|
|
+ await _db.Ado.RollbackTranAsync();
|
|
|
+ throw;
|
|
|
+}
|
|
|
+if (pendingPulse) _wake.Pulse(); // ← 必须在提交之后
|
|
|
+```
|
|
|
+
|
|
|
+**关键约束**:`MdpOutboxEnqueueService.TryEnqueueAsync` 内部第 36 行会立即 `Pulse()`,事务未提交时 worker 可能读不到该行而空转。**解决方式二选一**(推荐 A):
|
|
|
+
|
|
|
+- **A(推荐,不改公共类)**:`CreatePickBillService` 直接注入 `MdpOutboxWakeSignal`,在事务内改用不触发 Pulse 的路径——为 `MdpOutboxEnqueueService` 增加**可选参数** `bool pulse = true` 的重载,调用方传 `false`,提交后自己 `Pulse()`。默认值保持 `true`,**不影响 `WmsBaseDataService` 等既有调用方**。
|
|
|
+- B:不改公共类,接受一次可能的空转(兜底 Job 60 秒后会捡起)。**仅在赶工期可接受,须在代码注释登记技术债。**
|
|
|
+
|
|
|
+#### 5) idem_key 改业务键(修 F5)
|
|
|
+
|
|
|
+```csharp
|
|
|
+// 现状(:172/:189):trace 含 Guid → 每次调用都是新键,去重永不命中
|
|
|
+var trace = $"pick|{domain}|{DateTime.Now:yyyyMMddHHmmss}|{Guid.NewGuid():N}"[..48];
|
|
|
+var idem = $"{trace}|wom|{wo}";
|
|
|
+
|
|
|
+// 改为
|
|
|
+var trace = $"pick|{domain}|{DateTime.Now:yyyyMMddHHmmss}|{Guid.NewGuid():N}"[..48]; // 仅日志串联,保留
|
|
|
+var idemWom = $"pick|{domain}|{wo}|wom"; // 工单头
|
|
|
+var idemWor = $"pick|{domain}|{wo}|{op}|wor"; // 逐工序,op 为 int 工序号
|
|
|
+```
|
|
|
+
|
|
|
+**同时修 `WmsBaseDataService`**:§7.0 实测其 idem_key 也带随机尾巴(id 6/7 为同一业务对象的两行)。改为纯业务键 `wmsbase|{table}|{key1=v1}|{key2=v2}`,去掉尾部哈希。
|
|
|
+
|
|
|
+> `action_code` 沿用现值 `PICK_WOM_UPSERT`;**不要**改回旧的 `PICK_WOM_STATUS`。
|
|
|
+
|
|
|
+#### 6) 165 里程碑工序改走 Outbox(修 F8)
|
|
|
+
|
|
|
+现 :202–209 是裸 `ExecuteCommandAsync`,失败即抛且无重试。改为:
|
|
|
+
|
|
|
+**165 `WorkOrdRouting` 关键列(已实测确认,勿再猜)**
|
|
|
+
|
|
|
+| 列 | 类型 | 说明 |
|
|
|
+|----|------|------|
|
|
|
+| `Domain` | `nvarchar(8) NOT NULL` | |
|
|
|
+| `WorkOrd` | `nvarchar(24) NOT NULL` | |
|
|
|
+| **`OP`** | **`int NOT NULL`** | **工序号。列名是 `OP`,不是 `Oper`/`Operation`** |
|
|
|
+| `MilestoneOp` | `bit NOT NULL` | 非空,故 `ISNULL(MilestoneOp,0)=1` 可简化为 `MilestoneOp=1` |
|
|
|
+| `Status` | `nvarchar(1) NULL` | **只有 1 个字符**,写 `'r'` 正好;任何多字符值都会被截断或报错 |
|
|
|
+| `ID` | — | 见下方警告 |
|
|
|
+| `RecID` | `int` | 主键 `PK_WorkOrdRouting_1` |
|
|
|
+
|
|
|
+**唯一索引 `IX_WorkOrdRouting_1` = `(Domain, ID, OP, WorkOrd)`**——注意含 `ID`。但实测本库 `WorkOrdRouting.ID` **全为 NULL**(Ai-DOP 不写该列),所以:
|
|
|
+
|
|
|
+> ⚠️ Outbox 的 `keys` 用 `{Domain, WorkOrd, OP}`,**不要**把 `ID` 放进 keys(值为 NULL 会导致等值匹配永不命中)。同时**必须校验 `affected = 1`**:若为 0 说明该工序在 165 不存在,若 > 1 说明 `Domain+WorkOrd+OP` 在 165 上不唯一,需回报而非放过。
|
|
|
+
|
|
|
+实现步骤:
|
|
|
+
|
|
|
+1. 先在 165 **只读**查出目标工序号:
|
|
|
+
|
|
|
+```sql
|
|
|
+SELECT OP FROM WorkOrdRouting
|
|
|
+ WHERE Domain = @d AND WorkOrd = @w
|
|
|
+ AND MilestoneOp = 1
|
|
|
+ AND ISNULL(Status, '') NOT IN ('C', 'c')
|
|
|
+```
|
|
|
+
|
|
|
+2. 对每个 `OP` 入一条 Outbox 消息(`MdpDbPushExecutor` 按**全 keys 等值**匹配,故必须逐工序):
|
|
|
+
|
|
|
+```jsonc
|
|
|
+{
|
|
|
+ "op": "UPDATE",
|
|
|
+ "table": "WorkOrdRouting",
|
|
|
+ "keys": { "Domain": "8010", "WorkOrd": "M500000005", "OP": 902 },
|
|
|
+ "update": { "Status": "r", "UpdateUser": "aidop", "UpdateTime": "2026-08-09 14:30:00" },
|
|
|
+ "expect": {}
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> `OP` 是 **int**,payload 里写数字不要加引号,否则 SQL Server 侧参数类型不匹配。
|
|
|
+
|
|
|
+**验收**
|
|
|
+
|
|
|
+全部以 `M500000005` 为用例(S0 已复位)。每条造数方式都写明了,执行者按序跑。
|
|
|
+
|
|
|
+| # | 场景 | 造数方式 | 期望 |
|
|
|
+|---|------|---------|------|
|
|
|
+| 1 | **F1 · 165 事务回滚** | 临时把 `NbrDetail` 插入 SQL 的 `@QtyOrd` 改成插字符串制造类型错误,只让第 2 条明细失败 | 165 上 `NbrMaster` 与 `NbrDetail` **均无残留**;接口返回失败。改回 SQL 后重跑应成功 |
|
|
|
+| 2 | **F2 · 孤儿不阻塞** | 手工在 165 插一条 `NbrMaster`(`Type='SM'`、`WorkOrd='M500000005'`)但**不插明细** | 重新建单**成功建出新单**;返回含 `reasonCode=ORPHAN_DETECTED` 与孤儿单号;孤儿行**仍在**(未被删) |
|
|
|
+| 3 | **F4 · 本库事务原子** | 临时让 `WorkOrdRouting` UPDATE 抛异常 | `WorkOrdMaster` 未被改为 `r`、`mdp_outbox` **无新增行** |
|
|
|
+| 4 | **F5 · 幂等键去重** | 同一工单连点两次 | `mdp_outbox` 中 `pick|8010|M500000005|wom` **只有一条** |
|
|
|
+| 5 | **F13 · 重放收敛(本步核心)** | ① 正常建单一次;② 手工 `DELETE FROM mdp_outbox` 模拟回写丢失;③ 确认 165 无该工单头;④ **再次调用建单接口** | 第 ④ 步返回 `result=existed` + **原号**(不产生新号),且**新入队一条工单头 UPSERT**;等 Outbox 推送后 165 上出现该工单头且 `Status='r'`、`Batch='500000005'` |
|
|
|
+| 6 | **F8 · 工序走 Outbox** | 正常建单。`M500000005` 的里程碑工序**已实测为 `OP = 902 / 1103 / 1201` 共 3 个** | `mdp_outbox` 中出现**恰好 3 条** `...\|wor` 消息(`OP` 分别为 902/1103/1201);推送后 165 对应工序 `Status='r'`,每条 `response_json` 的 `affected` **均为 1** |
|
|
|
+
|
|
|
+**自检**(提交前必跑)
|
|
|
+
|
|
|
+```powershell
|
|
|
+dotnet build server/Plugins/Admin.NET.Plugin.AiDOP/Admin.NET.Plugin.AiDOP.csproj -c Debug
|
|
|
+```
|
|
|
+
|
|
|
+**提交**:仅后端改动 → 只递增 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>`/`<AssemblyVersion>`/`<FileVersion>`(三处同号 patch +1),**不动** `Web/package.json`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### S2 · 判定与返回契约(D11 已定 A,可开工)
|
|
|
+
|
|
|
+| # | 落点 | 动作 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | `CreatePickBillService.cs` | 新增 `PickBillItemResult` / `PickBillResponse` DTO,按 §4.2 输出;`Create` 全程收集逐单结果,**不再中途 `return new { ok=false, message }`**(修 **F3/F9/F11**) |
|
|
|
+| 2 | 同上 | 删除所有业务性 `throw`(:124 取号不足、:430 RecID 为空),改为写入对应 `items[].reasonCode` 并继续/终止(修 **F10**) |
|
|
|
+| 3 | 同上 | 新增 `ClassifyMesError(Exception)`:`SqlException.Number ∈ {-2, 53, 4060, 18456, 10060, 10061}` 或 `TimeoutException`/`SocketException` → `MES_UNREACHABLE`;其余 `SqlException` → `MES_WRITE_FAILED`(附 `Number`);其它 → `MES_WRITE_FAILED` |
|
|
|
+| 4 | 同上 | 按 D11-A 实现 fail-fast:取号或 165 写入抛 `MES_UNREACHABLE` 时,**整批终止**、MySQL 事务不开启,返回 `code=FAILED` 且 `summary.created=0` |
|
|
|
+| 5 | 同上 | `writeback.state`:入队成功为 `pending`。**本包不做前端轮询**——回写进度统一到 S4a 的「出站回写队列」页查看(按 `idem_key` 前缀 `pick\|{domain}\|{workOrd}` 可检索该工单的全部回写消息)。前端只需按 §4.2 展示 `pending` 提示文案 |
|
|
|
+| 6 | `Web/src/views/aidop/s5/api/wmsPick.ts` | 同步 TS 类型定义 |
|
|
|
+
|
|
|
+**验收**
|
|
|
+
|
|
|
+- [ ] 勾 5 张(3 张可建、1 张已建、1 张无明细):`summary = {requested:5, created:3, existed:1, skipped:1, failed:0}`,`code=PARTIAL`。
|
|
|
+- [ ] 全部已建:`code=NOOP`、`ok=true`(**不是** `false`),逐单返回原 `nbr`。
|
|
|
+- [ ] 断开 165(改错端口):`code=FAILED`、`reasonCode=MES_UNREACHABLE`,本库工单状态**未变**,`mdp_outbox` 无新增。
|
|
|
+- [ ] 全流程无 HTTP 500(业务失败一律 200)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### S3 · 前端呈现
|
|
|
+
|
|
|
+| # | 落点 | 动作 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | `workOrderDispatchList.vue` :312–363 | 结果改为**可滚动明细对话框**:顶部四个统计徽标(成功/已存在/跳过/失败),下方 `el-table` 逐行展示 `workOrd / result / nbr / reason / hint`(修 **F12**) |
|
|
|
+| 2 | 同上 | 按 `code` 决定标题与图标色:`OK` 绿、`PARTIAL` 橙、`NOOP` 蓝、`FAILED` 红。`NOOP` **不得**用 `ElMessage.error` |
|
|
|
+| 3 | 同上 | 单工单行操作(`onCreatePickBillRow`)结果只有一条时,仍用 `ElMessage`,但按 `code` 选择 `success`/`warning`/`info`/`error` |
|
|
|
+| 4 | 同上 | `writeback.state=pending` 时在弹窗底部提示「165 工单状态回写正在后台进行,稍后刷新可见」(修 **F11**) |
|
|
|
+| 5 | 同上 | 失败行的 `hint` 可点击:`NO_KEEPER` 跳 S5 物料职责维护页,`NO_DETAIL` 跳工单明细 |
|
|
|
+
|
|
|
+**验收**
|
|
|
+
|
|
|
+- [ ] 批量 20 张单的结果可完整阅读、可滚动、可复制单号。
|
|
|
+- [ ] 重复点击已建单的工单:出现蓝色信息弹窗并展示原单号,**无红色报错**。
|
|
|
+- [ ] 失败行的 hint 链接可跳转到对应维护页。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### S4a · Outbox 可观测与手工重推(**联调刚需,无决策依赖,可直接开工**)
|
|
|
+
|
|
|
+> 这半截解决的是「回写失败了没人知道」。§7.1 的故障从发生到被发现隔了一整天,靠的还是人工翻表。**联调期没有这个页面,每次链路不通都要查库定位。**
|
|
|
+
|
|
|
+#### 1) 路由错误可诊断
|
|
|
+
|
|
|
+`MdpTargetPushDispatcher.Resolve` :93–104 的兜底分支(既无 `source_type` 又无 `DbHost`)当前**静默落到 API 执行器**,由后者报出误导性的「未配置 API」——§7.1 的排查就卡在这句话上。改为:
|
|
|
+
|
|
|
+```csharp
|
|
|
+// 兜底分支不再猜,直接给出可诊断的错误
|
|
|
+_logger.LogWarning(
|
|
|
+ "[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
|
|
|
+ source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
|
|
|
+return MdpPushResult.Fail($"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName");
|
|
|
+```
|
|
|
+
|
|
|
+#### 2) 配置类失败不烧重试次数(过渡措施)
|
|
|
+
|
|
|
+目标源未启用(:55–60)与路由不可解析属**环境可修复**,不该立即变成永久死信。S4b 的退避未落地前的过渡做法:**标记失败但不递增 `retry_count`**,使配置补好后能被 60 秒兜底作业自动捡起。
|
|
|
+
|
|
|
+> 这是 §7.1 故障的直接教训:源配置后来补对了,但那 4 条消息永远躺在 `status=2`。
|
|
|
+
|
|
|
+#### 3) 新增管理服务 `DataPlatform/MdpOutboxAdminService.cs`
|
|
|
+
|
|
|
+参照同目录既有服务的写法(`IDynamicApiController, ITransient`,路由 `api/aidop/...`)。**本服务读写本库敏感运维数据,不要加 `[AllowAnonymous]`**。
|
|
|
+
|
|
|
+| 端点 | 入参 | 出参 |
|
|
|
+|------|------|------|
|
|
|
+| `GET /api/aidop/mdp-outbox/page` | `status?`(0/1/2)、`targetSourceCode?`、`actionCode?`、`idemKey?`(模糊)、`createTimeFrom/To?`、`page`、`pageSize` | 分页;含 `payloadJson`、`errorMsg` 全文 |
|
|
|
+| `GET /api/aidop/mdp-outbox/stats` | — | `{ pending, success, dead, oldestPendingMinutes, deadLast24h }` |
|
|
|
+| `POST /api/aidop/mdp-outbox/retry` | `{ ids?: long[], allDead?: bool }` | 置 `status=0, retry_count=0, error_msg=null` 并 `Pulse()`;返回重置条数 |
|
|
|
+
|
|
|
+`retry` 的实现要点:`ids` 与 `allDead` 二选一,两者都空时**报错而非全表重置**;重置后调用 `MdpOutboxWakeSignal.Pulse()` 立即触发推送。
|
|
|
+
|
|
|
+#### 4) 新增前端页
|
|
|
+
|
|
|
+**落点对齐既有数据中台页面**(`Web/src/views/aidop/data-platform/` 下已有 `sources.vue`、`syncTasks.vue`、`syncLogs.vue`、`actionRunLogs.vue`、`mdpMonitor.vue`),本页归入同一目录:
|
|
|
+
|
|
|
+| 项 | 值 |
|
|
|
+|----|-----|
|
|
|
+| 组件 | `Web/src/views/aidop/data-platform/outbox.vue` |
|
|
|
+| 路由 path | `/aidop/data-platform/outbox` |
|
|
|
+| route name | `aidopDataPlatformOutbox` |
|
|
|
+| **FUNC 编号** | **`FUNC-S9-016`**(现 S9 段最大为 `FUNC-S9-015` 业务动作日志,续号) |
|
|
|
+| 菜单标题 | `出站回写队列`(**只写业务短名**,不得把 FUNC 写进 `Title`) |
|
|
|
+
|
|
|
+页面内容:顶部 `stats` 四个统计卡(待推 / 成功 / 死信 / 最老待推分钟数)+ 状态筛选 + 列表(`action_code`、`target_source_code`、`idem_key`、`retry_count`、`update_time`)+ 行展开看 `payload_json`/`error_msg` + 「重推选中」「重推全部死信」两个按钮(后者加二次确认)。
|
|
|
+
|
|
|
+参照 `actionRunLogs.vue` 的列表与筛选写法,保持风格一致。
|
|
|
+
|
|
|
+#### 5) 登记(**两处都要改,缺一不可**)
|
|
|
+
|
|
|
+- `Web/src/constants/aidopFuncCodes.ts` 的 `FUNC_DEFS` 增加:
|
|
|
+ `{ code: 'FUNC-S9-016', name: '出站回写队列', paths: ['/aidop/data-platform/outbox'], names: ['aidopDataPlatformOutbox'] }`
|
|
|
+- `server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs` 增加对应 `sys_menu` 叶子,挂在数据中台目录下,`Title='出站回写队列'`。
|
|
|
+
|
|
|
+详见 [`aidop-func-code-menu`](../../../../.cursor/rules/aidop-func-code-menu.mdc)。
|
|
|
+
|
|
|
+**验收**
|
|
|
+
|
|
|
+| # | 场景 | 期望 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | 造一条死信(把 `mdp_source.status` 改 0 后入队一条消息) | 监控页可见该行,`error_msg` 全文可展开 |
|
|
|
+| 2 | 修好源配置后点「重推」 | 状态回 0 并被 worker 立即处理,最终 `status=1` |
|
|
|
+| 3 | 把 `mdp_source.source_type` 置空后入队 | 报错为 `SOURCE_ROUTE_UNRESOLVED` 并在日志打出实际取值,**不再是**「未配置 API」;且 `retry_count` 未递增 |
|
|
|
+| 4 | `POST retry` 不传 `ids` 也不传 `allDead` | 报错,**不得**全表重置 |
|
|
|
+| 5 | 菜单 | 侧栏出现「出站回写队列 [016]」,面包屑显示 `FUNC-S9-016` |
|
|
|
+
|
|
|
+**自检 + 提交**:前后端都改 → `dotnet build` + `npm run build`(在 `Web/`);**前端 `Web/package.json` 与后端 `.csproj` 版本号各 patch +1**。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### S4b · Outbox 退避与告警(依赖 D12,**跨模块,可推迟到上线前**)
|
|
|
+
|
|
|
+> **本步影响所有走 `mdp_outbox` 的出站回写**,不止领料。派发前须确认 §6 跨模块影响已知会。
|
|
|
+>
|
|
|
+> **优先级说明**:退避主要防的是「目标库长时间不可用」。开发测试阶段 165 一直在线,收益有限;但上线前必须做完。
|
|
|
+
|
|
|
+| # | 落点 | 动作 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | `Entity/DataPlatform/MdpOutbox.cs` | 新增 `next_retry_time`(`DateTime?`)、`last_error_code`(`string?`, 64)两列 |
|
|
|
+| 2 | `server/Admin.NET.Web.Entry/UpdateScripts/<新版本>.sql` | `ALTER TABLE mdp_outbox ADD COLUMN next_retry_time datetime NULL, ADD COLUMN last_error_code varchar(64) NULL;` + `CREATE INDEX ix_mdp_outbox_pending ON mdp_outbox(status, next_retry_time, id);` |
|
|
|
+| 3 | `MdpTargetPushDispatcher.cs` :34–38 | 待推查询加 `AND (next_retry_time IS NULL OR next_retry_time <= NOW())` |
|
|
|
+| 4 | 同上 :72–78 / :80–87 | 失败时按退避序列写 `next_retry_time`;`MaxRetry` 3 → 6(修 **F6**) |
|
|
|
+| 5 | 同上 | 区分**可重试**与**不可重试**失败:payload 反序列化失败、SQL 约束冲突 → 直接 `status=2`;连接类与配置类错误 → 走退避 |
|
|
|
+| 6 | 新增 `Job/MdpOutboxDeadLetterAlertJob.cs` | 每 5 分钟扫:`status=2` 新增数 > 0 或最老 `status=0` 年龄 > 15 分钟 → 按 D12 渠道告警 |
|
|
|
+
|
|
|
+**验收**
|
|
|
+
|
|
|
+- [ ] 停掉 165 连接 30 分钟后恢复:消息**未变死信**,恢复后自动推成功(退避生效)。
|
|
|
+- [ ] 构造一条 SQL 约束冲突的消息:立即 `status=2`,`retry_count` 未被浪费。
|
|
|
+- [ ] 积压超阈值时告警触发一次(不刷屏)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. 跨模块影响(改前必须知会)
|
|
|
+
|
|
|
+| 模块 | 影响 | 缓解 |
|
|
|
+|------|------|------|
|
|
|
+| **WP7 六张基础数据回写**(在用) | S4b 的退避与 `MaxRetry` 变更同样作用于它;`mdp_outbox` 加列需停机或在线 DDL。另:§7.0 实测其幂等键同样被 Guid 污染(id 6/7 同一业务对象两行),S1 第 5 条的业务键改造应一并覆盖 `WmsBaseDataService` | 加列为 nullable,兼容旧行;`next_retry_time IS NULL` 视为立即可推 |
|
|
|
+| **所有出站回写** | S4b 改的是公共分发器,节奏从「3 次/2 分钟」变为「6 次/约 3.5 小时」 | 失败可见性由 S4a 监控页先行补上;急件可手工重推 |
|
|
|
+| **S1 工单池下达页**(在用) | S2/S3 改返回结构,前后端必须**同版本发布** | 前后端同一提交内改完;`wmsPick.ts` 与 DTO 对齐 |
|
|
|
+| **WP9 建单路径收敛** | 本包定义的返回契约将成为 `IPickBillCreator` 的输出形态 | WP9 S2 抽接口时直接采用 §4.2,不要另造 |
|
|
|
+| **WP9 决策 D9(按钮语义)↔ 本包 S3 前端** | D9 决定 S1 工单池那两个按钮(「工单下达」与「MES下达建领料」)是否合并。S3 正在改的就是这个页面:**结果弹窗、错误码图标、`hint` 跳转在任一 D9 结果下都可复用,但按钮布局与触发入口会返工** | S3 实现时把结果弹窗做成**独立组件**(如 `PickBillResultDialog.vue`),不要把弹窗逻辑写死在按钮的 click 处理里;D9 落定后只需换调用点。**不构成阻断**,但卡片 D 执行者须知悉 |
|
|
|
+| **WP6 对账** | 新增孤儿单、Outbox 死信、跨库半成品三项输入;且 WP6 原自拟的重放端点 `POST /api/aidop/mdp/outbox/replay` 与 S4a 重复 | ✅ 已在 WP6 §2.1/§2.4 登记;重放端点已统一为 S4a 的 `/api/aidop/mdp-outbox/retry`,WP6 §4.2 标注原端点作废 |
|
|
|
+| **WP4 字段共管矩阵** | S1 第 6 节把里程碑工序改走 Outbox,`WorkOrdRouting.Status` 回写通道变更 | ✅ 已回填 [`附-字段共管矩阵.md`](./附-字段共管矩阵.md) §13.1(白名单列不变,仅通道与幂等键变) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 盘点(**已于 2026-08-09 执行完毕,结论见下**)
|
|
|
+
|
|
|
+> **环境定性**:`123.60.180.165` 当前是**开发测试服务器**,库中数据全部为本项目冒烟测试产物。因此下表中的 0 值**不构成「系统健康」的证据**,只说明尚未跑过真实数据量;同理,发现的问题是**缺陷实证而非生产事故**。存量数据可直接清理,缺陷本身必须修——同一份代码将来要跑真实 MES/WMS 库。
|
|
|
+
|
|
|
+### 7.0 实测结果
|
|
|
+
|
|
|
+| 指标 | 数值 | 判读 |
|
|
|
+|------|------|------|
|
|
|
+| 165 孤儿领料单(有主无明细) | **0 条** | **不能据此认为 F1 风险低**——样本仅 1 张单,未跑过并发与批量。D10 降级的真正理由是「dev 可随手清 + 代码不内置删除」,不是这个 0 |
|
|
|
+| 165 一工单多张有效 SM | **0 条** | 同上,样本不足 |
|
|
|
+| 165 `NbrMaster(Type='SM')` / `NbrDetail` | **1 头 / 35 行** | 冒烟测试产物 `SM20260809003`,单据完整 |
|
|
|
+| 165 `WorkOrdMaster` 总行数 | **0 行** | ← **异常,见 7.1** |
|
|
|
+| 本库 `mdp_outbox` 总行数 | 8 条 | |
|
|
|
+| 其中 **死信**(`status=2`) | **4 条**(id 4/5/6/8) | **F7 实证**:躺了一整天无人知晓。这条与样本量无关,是机制缺失 |
|
|
|
+| 受 Guid 污染的幂等键 | 至少 2 组(id 6/7 同一业务对象两行;id 8) | **F5 实证**,且不止领料链,`WMS_BASE_*` 同样中招 |
|
|
|
+
|
|
|
+### 7.1 实测到的活例:`M500000005` 已处于 F13 卡死态
|
|
|
+
|
|
|
+| 位置 | 实际状态 |
|
|
|
+|------|---------|
|
|
|
+| 本库 `WorkOrdMaster` | `Status='r'`、`Batch='500000005'`、`UpdateTime=2026-08-09 12:18:52` —— **已下达** |
|
|
|
+| 165 `NbrMaster` | `SM20260809003`,35 行明细 —— **领料单已建成** |
|
|
|
+| 165 `WorkOrdMaster` | **该工单不存在**(全表 0 行)—— **回写从未发生** |
|
|
|
+| `mdp_outbox` id=8 | `PICK_WOM_STATUS`、`status=2`、`error_msg='目标源 DOPDEMORQ_SQLSERVER 未配置 API'` —— **死信** |
|
|
|
+
|
|
|
+**成因链**:入队时 `mdp_source.DOPDEMORQ_SQLSERVER` 未被识别为 DB 型(`MdpTargetPushDispatcher.Resolve` 落到 `MdpApiPushExecutor`,该执行器因 `ApiBaseUrl` 为空返回 `未配置 API`)→ 消息进入 `status=2` 终态。**该源现已修正为 `source_type='DB'` 且 `db_host/db_name` 齐全**,但死信是终态,**不会被任何机制重新拾起**。
|
|
|
+
|
|
|
+**卡死判定**:重放建单请求会命中幂等(`NbrMaster` 存在**且有明细**,连 D10-A 的孤儿口径都不触发),整单跳过 → 缺失的 165 工单头**永远补不上**。这正是 F13。注意 **S1 第 2 节的孤儿口径无法覆盖此例**(这张单有明细,判定正确),必须靠 **S1 第 3 节「补做」**。
|
|
|
+
|
|
|
+**这几条缺陷是串起来的**:F5(幂等键失效)让队列噪声掩盖了问题 → 路由配置错误让消息失败 → F6/F7(无退避、无监控)让它悄悄变成死信 → F13 让重放也救不回来。**任何单独一条修掉都不足以避免这次故障。**
|
|
|
+
|
|
|
+**对联调的直接影响**:F13 会让每一个建单失败过的测试工单**永久不可复用**,联调期必须反复手工清库。这使 S1 从「可靠性加固」变成**测试前置项**。
|
|
|
+
|
|
|
+### 7.2 待处置
|
|
|
+
|
|
|
+因 165 为开发测试机,存量数据**直接清理即可**,不必小心翼翼地逐条重推。
|
|
|
+
|
|
|
+| # | 事项 | 做法 | 归属 |
|
|
|
+|---|------|------|------|
|
|
|
+| 1 | 4 条死信(id 4/5/6/8)与被 Guid 污染的队列 | dev 环境直接 `DELETE FROM mdp_outbox;` 清空重来;生产环境走 S4a 监控页手工重推 | 即时 / S4a |
|
|
|
+| 2 | `M500000005` 状态不一致 | 三处一起复位:清 165 的 `NbrMaster`/`NbrDetail`(`Nbr='SM20260809003'`)、把本库 `WorkOrdMaster.Status` 改回 `p`、清对应 Outbox 行;之后该工单可作为 S1 第 3 节「补做」的回归用例重新走一遍 | 即时(S0) |
|
|
|
+| 3 | ~~核查 `mdp_source` 路由配置被何者写成非 DB 型~~ **已查清,无潜伏 Bug** | `MdpSourceHealthCheckJob` :75–77 只写 `HealthStatus/HealthMsg/LastHealthCheck/UpdateTime`,**不碰 `source_type`**。故 `update_time` 近期变动仅为健康检查所致,不能据此推断配置变更时间。合理解释是:那批消息入队时该源**尚未被配置为 DB 型**,后来才在「数据源管理」页补全 | 已结案 |
|
|
|
+
|
|
|
+> 第 3 项虽无潜伏 Bug,但暴露的**机制缺陷成立**:配置未就绪期间入队的消息会被烧成永久死信,配置补好后无法自动恢复。这正是 S4a 第 2 条与 S4b 第 5 条要修的。
|
|
|
+
|
|
|
+> 第 1、2 项的**可执行 SQL 见 §5 的 S0 步骤**(以那里为准,避免两处脚本分叉)。复位后 `M500000005` 同时是「F13 回归用例」与「S1 全链路验收用例」;**复位前请先把当前状态导出留档**,作为缺陷证据。
|
|
|
+
|
|
|
+### 7.3 盘点脚本(复跑用)
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ① 165:孤儿领料单(有主单无明细)—— 存量卡死工单清单
|
|
|
+SELECT m.RecID, m.Domain, m.Nbr, m.WorkOrd, m.Status, m.CreateTime, m.CreateUser
|
|
|
+FROM NbrMaster m
|
|
|
+WHERE m.Type = 'SM'
|
|
|
+ AND NOT EXISTS (SELECT 1 FROM NbrDetail d WHERE d.NbrRecID = m.RecID)
|
|
|
+ORDER BY m.CreateTime DESC;
|
|
|
+
|
|
|
+-- ② 165:一个工单多张有效 SM(重复建单检测)
|
|
|
+SELECT WorkOrd, COUNT(*) AS c, MIN(Nbr) AS min_nbr, MAX(Nbr) AS max_nbr
|
|
|
+FROM NbrMaster
|
|
|
+WHERE Type = 'SM' AND ISNULL(IsActive, 1) = 1
|
|
|
+GROUP BY WorkOrd HAVING COUNT(*) > 1;
|
|
|
+```
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ③ 本库 MySQL aidopdev:Outbox 健康度
|
|
|
+SELECT status, action_code, COUNT(*) AS cnt,
|
|
|
+ MIN(create_time) AS oldest, MAX(retry_count) AS max_retry
|
|
|
+FROM mdp_outbox
|
|
|
+GROUP BY status, action_code ORDER BY status, cnt DESC;
|
|
|
+
|
|
|
+-- ④ 本库:死信明细
|
|
|
+SELECT id, action_code, idem_key, retry_count, error_msg, create_time, update_time
|
|
|
+FROM mdp_outbox WHERE status = 2 ORDER BY id DESC;
|
|
|
+
|
|
|
+-- ⑤ 本库:幂等键是否已被 Guid 污染(F5 的存量证据)
|
|
|
+SELECT COUNT(*) AS guid_polluted FROM mdp_outbox
|
|
|
+WHERE action_code = 'PICK_WOM_UPSERT' AND idem_key REGEXP '[0-9a-f]{32}';
|
|
|
+```
|
|
|
+
|
|
|
+> 首次执行结果已直接记入 §7.0/§7.1,未单出 `_wp10_盘点.md`。复跑时对比五个数字:孤儿单条数 / 重复建单工单数 / 待推积压条数 / 死信条数 / 受污染幂等键条数。孤儿单清单**原样交客户 DBA**,我方不删。
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ⑥ 补充:路由配置核查(本次事故根因)
|
|
|
+SELECT source_code, source_type, status, db_host, db_port, db_name, api_base_url
|
|
|
+FROM mdp_source WHERE source_code = 'DOPDEMORQ_SQLSERVER';
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 回滚
|
|
|
+
|
|
|
+| 步骤 | 回滚方式 |
|
|
|
+|------|---------|
|
|
|
+| S1 | 代码回退。已建的 165 单不回删;新幂等口径比旧口径更宽松,回退后仅退化为旧的跳过行为,不产生脏数据 |
|
|
|
+| S2/S3 | 前后端**必须一起回退**(返回结构不兼容) |
|
|
|
+| S4a | 代码回退;监控页与管理端点为新增,无存量影响 |
|
|
|
+| S4b | 代码回退;`mdp_outbox` 新增列为 nullable 可保留不删。回退后 `next_retry_time` 被忽略,退化为旧的立即重试 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 9. 验收清单(交付前逐条勾)
|
|
|
+
|
|
|
+- [x] D10 已定 A、D11 已定 A(2026-08-09,已回填 §3)
|
|
|
+- [ ] D12 已决策并回填 §3(仅阻断 S4b)
|
|
|
+- [x] 盘点五个数字已出(§7.0);孤儿单 0 条(样本不足,不作为风险判据)
|
|
|
+- [x] §7.2 第 3 项路由根因已查清(健康检查作业不改 `source_type`,无潜伏 Bug)
|
|
|
+- [ ] S0 环境复位完成,`M500000005` 可作统一回归用例
|
|
|
+- [ ] **F1**:165 主从插入在同一事务内,中途失败无残留
|
|
|
+- [ ] **F2**:孤儿单不再阻塞重建(造数验证)
|
|
|
+- [ ] **F13**:幂等命中时补做缺失的跨库副作用;`M500000005` 已收敛到终态
|
|
|
+- [ ] **F3**:部分成功如实返回 `PARTIAL` 与逐单结果
|
|
|
+- [ ] **F4**:本库更新与 Outbox 入队同事务,`Pulse` 在提交后
|
|
|
+- [ ] **F5**:`idem_key` 为业务键,连点两次只入一条
|
|
|
+- [ ] **F6**:165 停 30 分钟后恢复,消息未死信
|
|
|
+- [ ] **F7**:死信可在监控页看到并手工重推
|
|
|
+- [ ] **F8**:里程碑工序更新走 Outbox 可重试
|
|
|
+- [ ] **F9/F10/F11**:`NOOP` 不报错、错误码分类准确、异步回写有提示
|
|
|
+- [ ] **F12**:批量结果以明细弹窗呈现
|
|
|
+- [ ] 全流程无业务性 HTTP 500
|
|
|
+- [ ] 前后端同版本发布,`wmsPick.ts` 与 DTO 一致
|
|
|
+- [ ] 新增菜单已按 `aidop-func-code-menu` 登记 FUNC 编号
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 10. 证据索引
|
|
|
+
|
|
|
+| 证据 | 位置 |
|
|
|
+|------|------|
|
|
|
+| 建领料主体 | `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs`(幂等探查 :75–82、165 直写 :144–145、本库更新 :147–169、Outbox 入队 :171–198、165 Routing 直写 :202–209、返回 :215–223、`InsertNbrOn165Async` :387–476) |
|
|
|
+| Outbox 入队与去重 | `.../DataPlatform/Executors/MdpOutboxEnqueueService.cs`(去重 :26–29、`Pulse` :36) |
|
|
|
+| Outbox 分发与重试 | `.../DataPlatform/Executors/MdpTargetPushDispatcher.cs`(`MaxRetry=3` :12、待推查询 :34–38、失败计数 :72–87、`MarkAsync` :106) |
|
|
|
+| Outbox 事件驱动 | `.../DataPlatform/Executors/MdpOutboxPushWorker.cs`(channel 驱动 :26) |
|
|
|
+| Outbox 兜底作业 | `.../Job/MdpOutboxPushJob.cs`(`PeriodSeconds(60), RunOnStart=true` :13) |
|
|
|
+| Outbox 实体 | `.../Entity/DataPlatform/MdpOutbox.cs`(**无** `next_retry_time`) |
|
|
|
+| 前端调用与提示 | `Web/src/views/aidop/business/workOrderDispatchList.vue`(:312–363) |
|
|
|
+| 前端 API 定义 | `Web/src/views/aidop/s5/api/wmsPick.ts` |
|
|
|
+| 不用分布式事务的约束来源 | [`00-总体方案.md`](./00-总体方案.md) §1 硬约束 |
|
|
|
+| 计数器与 APP 共用 | [`WP2-单号服务.md`](./WP2-单号服务.md) |
|
|
|
+| 事务写法参照 | `.../DataPlatform/MdpStdFullReplace.cs` :51(`Ado.BeginTranAsync`)、`.../MaterialWarehouse/IqcInspBillFlowService.cs` :74(`AsTenant().UseTranAsync`) |
|
|
|
+| 健康检查不改 `source_type` | `.../Job/MdpSourceHealthCheckJob.cs` :75–77(只写 `HealthStatus/HealthMsg/LastHealthCheck/UpdateTime`) |
|
|
|
+| 数据中台既有页面(S4a 参照) | `Web/src/views/aidop/data-platform/`(`sources.vue`、`syncLogs.vue`、`actionRunLogs.vue`、`mdpMonitor.vue`) |
|
|
|
+| S9 段 FUNC 现状 | `Web/src/constants/aidopFuncCodes.ts` :251–264(最大 `FUNC-S9-015`) |
|
|
|
+| 165 `WorkOrdRouting` 列与索引 | 实测 `INFORMATION_SCHEMA.COLUMNS` + `sys.indexes`:工序列为 **`OP` int**;`Status` 仅 `nvarchar(1)`;`MilestoneOp` 为 `bit NOT NULL`;唯一索引 `IX_WorkOrdRouting_1 = (Domain, ID, OP, WorkOrd)`;主键 `RecID` |
|
|
|
+| `WorkOrdRouting.ID` 为空 | 实测本库该列全为 NULL(Ai-DOP 不写),故不得入 Outbox keys |
|
|
|
+| `M500000005` 里程碑工序 | 实测 `OP = 902 / 1103 / 1201` 共 3 个,`MilestoneOp=1`、`IsActive=1` |
|
|
|
+| 165 `NbrMaster`/`NbrDetail` 关键列 | 实测均存在 `RecID`、`IsActive`;`NbrDetail.NbrRecID` 为 int,S1 探查 SQL 有效 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 11. 已知空白与前置假设(**派发前先看这节**)
|
|
|
+
|
|
|
+本书不是全知的。以下是**明确未覆盖**或**依赖执行者现场确认**的点,遇到时按此处置,不要自行猜测后继续。
|
|
|
+
|
|
|
+| # | 空白 | 影响步骤 | 处置 |
|
|
|
+|---|------|---------|------|
|
|
|
+| 1 | **D12 未定**(退避序列、`MaxRetry`、告警渠道) | S4b | S4b 不得开工。S0–S4a 不受影响 |
|
|
|
+| 2 | **S2/S3/S4b 为契约级而非代码级** | S2/S3/S4b | §4.2 的 JSON、§4.3 的错误码表是**强约束**,字段名与错误码不得自创;其余实现细节由执行者按本仓既有风格决定 |
|
|
|
+| 3 | **`MdpDbPushExecutor` 的 UPSERT/UPDATE 具体行为未在本书复述** | S1 第 6 节、S4a | 以 [`WP3-回写执行器.md`](./WP3-回写执行器.md) 为准;本书只规定 payload 形状与 keys 选择 |
|
|
|
+| 4 | **165 `WorkOrdRouting.ID` 语义不明** | S1 第 6 节 | 实测本库全为 NULL、165 表为空,无法判断该列本意。**按本书写法(keys 不含 `ID`)实现,并强制校验 `affected=1`**;若联调中出现 `affected>1`,停下来回报,不要放过 |
|
|
|
+| 5 | **165 单据表当前全部为空**(`WorkOrdMaster` 0 行) | 全部验收 | 所有验收都要先造数。`M500000005` 是唯一已备好的用例(本库有工单头 + 明细 + 3 个里程碑工序 OP 902/1103/1201) |
|
|
|
+| 6 | **并发场景未验证** | S1 | 本书的事务与幂等设计未经并发压测。样本仅 1 张单,§7.0 的两个「0 条」**不构成健康证据** |
|
|
|
+| 7 | **`NO_KEEPER` 目前根本不会被触发** | S2 实现 + S2/S3 验收 | 165 `EmpWorkDutyMaster` 为 0 行,但 `KeeperResolveService` **未实现 WP7 §3.2 的中断校验**——未命中只是 `continue` 跳过,建单照常成功、`User1` 为空。**S2 要交付 `NO_KEEPER`,必须同时补上这条硬校验**,否则该错误码是死代码。跑通成功路径另需补该表,见 [`WP7 §3.5 派发卡片`](./WP7-基础数据与任务推送.md#35-派发卡片--empworkdutymaster-联调补数);实现差距全表见 WP7 §3.4 |
|
|
|
+| 8 | **本书只覆盖建领料一条链路** | — | 退料(`WOD`)、完工入库(`WOI`)、报工等未纳入。它们将来照 §4.2/§4.3 的契约扩展,但本轮不做 |
|
|
|
+
|
|
|
+> **执行者纪律**:碰到本表未列、且本书也没写清楚的分歧点,**停下来回报**,不要自行选一种实现继续往下做。跨库链路上一个猜错的假设,代价是后面所有验收都白跑。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 12. 派发卡片(交给执行模型时按此复制)
|
|
|
+
|
|
|
+每张卡片**须连同以下文件一起交付**:本文档、[`00-总体方案.md`](./00-总体方案.md)、[`00B-执行须知(模型输入契约).md`](./00B-执行须知(模型输入契约).md)。**并要求执行者先读 §11 已知空白**。
|
|
|
+
|
|
|
+### 卡片 0 · 物料职责补数(**前置,不依赖任何代码改动,可立即并行派发**)
|
|
|
+
|
|
|
+> 执行 [`WP7 §3.5`](./WP7-基础数据与任务推送.md#35-派发卡片--empworkdutymaster-联调补数)。165 `EmpWorkDutyMaster` 为 0 行,不补则**成功路径永远跑不通**——建单虽会成功,但 `NbrMaster.User1` 为空,属静默错单。优先用 WP7 §2 的维护 API 补(顺带验证六表回写链路),API 不通再走 SQL。
|
|
|
+>
|
|
|
+> **先读 WP7 §3.4**:现有 `KeeperResolveService` 与 §3.1/§3.2 有 7 项差距,本卡片**只补数据、不改代码**,不要顺手去实现那些校验(属卡片 D 范围)。
|
|
|
+>
|
|
|
+> 与卡片 A/B/C **无依赖**,可最先执行。纯数据操作,不涉及版本号。
|
|
|
+
|
|
|
+### 卡片 A · S0 环境复位
|
|
|
+
|
|
|
+> 执行 WP10 §5 的 S0。这是纯 SQL 操作,不改代码。165(`123.60.180.165`/`dopdemorq`)是开发测试机,数据可清。执行前先把 §7.0、§7.1 两张表的当前实际值查出来贴进回复作为留档,再执行清理,最后复查三处均已复位。**不要**顺手清理 `NbrDayInfo` 以外的任何 165 主数据表。
|
|
|
+
|
|
|
+### 卡片 B · S1 事务边界与重放收敛
|
|
|
+
|
|
|
+> 执行 WP10 §5 的 S1。只允许修改 `CreatePickBillService.cs`(以及 §5 S1 第 5 条要求的 `WmsBaseDataService` idem_key、第 4 条可选的 `MdpOutboxEnqueueService` 重载)。**禁止**修改 `MdpTargetPushDispatcher`、`MdpOutboxPushWorker`、`MdpOutbox` 实体——那是 S4 的范围。
|
|
|
+>
|
|
|
+> 核心是 **S1 第 3 节「补做」**:本接口语义已改为「把工单推进到已下达终态」,幂等命中时**不得整单跳过**。做完按 S1 验收表 6 条逐条造数验证并贴出结果。跑 `dotnet build` 通过后,只递增后端版本号三处(`.csproj` 三个字段同号 patch +1),**不动** `Web/package.json`。**不要** `git push`,等负责人确认。
|
|
|
+
|
|
|
+### 卡片 C · S4a Outbox 可观测
|
|
|
+
|
|
|
+> 执行 WP10 §5 的 S4a。新增管理服务 + 前端页 + 菜单登记,另修 `MdpTargetPushDispatcher.Resolve` 的兜底分支与配置类失败的 retry 计数。**不要**动退避逻辑与 `MdpOutbox` 表结构(那是 S4b,受 D12 阻断)。
|
|
|
+>
|
|
|
+> 前端页落在 `Web/src/views/aidop/data-platform/outbox.vue`,风格参照同目录 `actionRunLogs.vue`。FUNC 编号固定用 `FUNC-S9-016`,`sys_menu.Title` 只写「出站回写队列」。前后端版本号各 patch +1。
|
|
|
+
|
|
|
+### 卡片 D · S2 + S3 返回契约与前端呈现(**D11 已定 A,可发**)
|
|
|
+
|
|
|
+> 执行 WP10 §5 的 S2 与 S3,**必须同一提交**(返回结构不兼容,前后端不能分开发布)。严格按 §4.2 的 JSON 契约与 §4.3 的错误码表实现,不要自创字段名或错误码。业务失败一律 HTTP 200 + 结构化 body。
|
|
|
+>
|
|
|
+> **D11 已定 A(整批 fail-fast)**:165 取号或建单报 `MES_UNREACHABLE` 时,MySQL 本地事务**不开启**,本库状态保持原值、`mdp_outbox` 无新增,返回 `code=FAILED` 且 `summary.created=0`。依据见 §3。
|
|
|
+>
|
|
|
+> **`NO_KEEPER` 附带工作量**:该错误码目前**无法被触发**——`KeeperResolveService` 未命中职责时只 `continue` 跳过。本卡片须同时实现 WP7 §3.2 的两条硬校验(`Location` 为空、存在未匹配物料),且校验必须在写第一行 `NbrMaster`/`NbrDetail` **之前**完成,避免产生半张单。详见 §11 第 7 项与 WP7 §3.4。
|
|
|
+>
|
|
|
+> **同一文件顺带落 WP7 §3.6(决策 B5)**:`KeeperResolveService` 的区间比较改为 `Trim + ToUpperInvariant` 归一后用 `StringComparison.Ordinal`;取数 SQL 补 `ORDER BY ItemNum1, ItemNum2, RecID` 并按「最窄区间优先」定重叠策略(现无 `ORDER BY`,重叠时命中结果不确定);字符集越界时报 `KEEPER_RULE_UNSAFE` 而非猜测。**改的是同一个方法,务必与上面的硬校验合并为一次改动**,不要分两轮改同一段匹配逻辑。§3.6 第②步(写入边界校验)属 `WmsBaseDataService`,若本卡片不便涉及可拆出,但须在回复中明确标注未做。
|
|
|
+>
|
|
|
+> **前置**:S1 必须已合入(S2 依赖其事务边界与三分类探查)。前后端版本号各 patch +1。
|
|
|
+
|
|
|
+### 卡片 E · S4b Outbox 退避与告警(**D12 定了才发**)
|
|
|
+
|
|
|
+> 执行 WP10 §5 的 S4b。**这一步改的是公共分发器,影响所有出站回写模块**(含 WP7 六张基础数据表),改前先读 §6 跨模块影响。表结构变更须同时提供 `server/Admin.NET.Web.Entry/UpdateScripts/<新版本>.sql`,新增列必须 nullable。
|