Просмотр исходного кода

feat(s2): Mes 模式工单物料变更经 Outbox 回写 165 领料单

UAT-INT-001:已下达工单改物料后入队 PICK_NBR_SYNC,在 165 本地事务同步最新有效 SM,不再写本库 Nbr*。chore: bump version server 1.0.381

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 1 день назад
Родитель
Сommit
8baddf3c7b

+ 12 - 0
doc/plan/UAT测试/README.md

@@ -0,0 +1,12 @@
+# UAT 测试(内部)
+
+本目录只放**内部 UAT** 过程记录,不是客户用例、也不是造数任务书。
+
+| 文件 | 用途 |
+|------|------|
+| [`接手速览-旧DOP与165与新系统.md`](./接手速览-旧DOP与165与新系统.md) | **新会话 / 新模型先读这份**:三套系统边界、165 红线、当前架构、UAT-INT-001 接手契约 |
+| [`内部UAT测试待办项.md`](./内部UAT测试待办项.md) | 要排期实现的缺口(当前:`UAT-INT-001` 物料变更回写 165) |
+| [`内部UAT测试问题记录表.md`](./内部UAT测试问题记录表.md) | 已解答、**不改程序**的口径题(库存流水 / `ic_item_stock`) |
+
+客户 UAT 造数、三套租户、验收剧本在 [`../UAT测试数据准备/README.md`](../UAT测试数据准备/README.md)。  
+165 对接总方案在 [`../旧DOP-MES-WMS/对接任务书/README.md`](../旧DOP-MES-WMS/对接任务书/README.md)。

+ 95 - 0
doc/plan/UAT测试/内部UAT测试待办项.md

@@ -0,0 +1,95 @@
+# 内部 UAT 测试待办项
+
+> 新会话 / 新模型先读同目录 [`接手速览-旧DOP与165与新系统.md`](./接手速览-旧DOP与165与新系统.md),再做本文件任务。
+
+| 项 | 内容 |
+|----|------|
+| 目录用途 | 内部 UAT 过程中发现、需产品/研发跟进的缺口(不是客户 UAT 用例本身) |
+| 登记日期 | 2026-08-27 |
+| 状态 | **已实现**(server `1.0.402`,`PICK_NBR_SYNC`);待现场用已下达工单改物料验收 |
+
+---
+
+## UAT-INT-001 工单物料中途变更后,领料单未回写 165
+
+| 项 | 内容 |
+|----|------|
+| **责任模块** | **S2 制造协同**(主归属) |
+| 功能入口 | 制造协同 → 生产排程 / 工单物料明细(`FUNC-S2-011`) |
+| 优先级 | P1(内部 UAT 已踩到;是否本轮客户验收必做,由负责人定) |
+| 性质 | **能力缺口**,不是「仓库多一步操作」的设计;也不是 Mes 建单写 165 的缺陷 |
+| 关联方案 | [`WP9-领料建单双路径统一.md`](../旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md)(D7-A);[`附-字段共管矩阵.md`](../旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md) §5、§13.1 |
+
+### 1. 模块归属
+
+| 模块 | 角色 | 是否本项主责 |
+|------|------|----------------|
+| **S2 制造协同** | 人改的是已下达工单的物料(数量/增行/删行);保存、删除挂在 `WorkOrderSchedulingService` / `ProductionSchedulingActionService` | **是** |
+| S1 产销协同 | 「工单下达」首次建 SM(`CreatePickBillService`);评审改量偶发带动领料刷新(`OrderReviewOrchestrationService`) | 否(建单已按 WP9 落地;本项不重做建单) |
+| S5 仓储协同 | 「生产领料单」只读 `mdp_std_production_issue`,验收「回写后列表能否看到」 | 否(无写接口,不要按 S5 功能排期) |
+| MDP / Outbox | 回写通道与入站展示;`WO_MES_WOD` 只覆盖工单明细层,**不能替代**领料单同步 | 协作,不单独成模块号 |
+
+**结论**:内部排期、任务书、缺陷单都写 **S2**。S5 只做回写后的列表验收。
+
+### 2. 测试员现象(已核实)
+
+1. `AiDOP:PickBill:Mode = Mes` 时,下达建领料写入 **165** `dopdemorq.NbrMaster` / `NbrDetail`,本库带 `tenant_id` 的同名表**不会**出现新单。  
+   → **符合 WP9 D7-A,不是 bug。** 给测试的口径:权威库是 165;本库 `Nbr*` 只作入站镜像 / Local 降级。
+2. 工单已有领料单后,再改 **工单物料**(不是改领料单页面):165 上的 SM 明细不跟着变。  
+   「MES 补偿建领料」对已存在工单返回「已存在」,**不重建明细**。
+3. 现存同步逻辑仍写 **本库** `NbrMaster` / `NbrDetail`(`CreateOrUpdateNbrDetailForMaterial`、删料关行、`SyncPickingListDetailsAsync` 等),Mes 模式下对权威库无效。
+4. 样例(UAT 现场,165):工单 **`M500000001`** 上同时有 **`SM20260826001`**、**`SM20260826002`**。  
+   违反 WP9 不变量「一工单权威库最多一张有效 SM」。回写实现时须先约定改哪一张(建议:最新一张未关闭单)。
+
+S5「生产领料单」不能改明细,不能当作修复入口。
+
+### 3. 已对齐、待实现的做法(设计,未编码)
+
+前提:用户先保存本地 `WorkOrdDetail`(计划变更权威仍在本库)。
+
+| # | 约定 |
+|---|------|
+| 1 | Mes 模式:**停止**再把物料变更写本库 `Nbr*`;改为出站 Outbox(建议类型 `PICK_NBR_SYNC`),与 `PICK_WOM_UPSERT` / `S2_PSD_*` 同通道。不引入 XA,不双写。 |
+| 2 | 165 `NbrMaster` / `NbrDetail` 为领料单权威。同一 165 本地事务内 INSERT / UPDATE / 关行;需要 `RecID`。 |
+| 3 | **禁止**回写 `QtyRec`、`User1` / `User2`(WMS 已领、保管员属 165 侧)。 |
+| 4 | 幂等键建议:`pick|{domain}|{workOrd}|sm-sync`。 |
+| 5 | 推送成功后 `EnrollPickBillAsync` → HotWatch → stg → `mdp_std_production_issue`,S5 才能看到。 |
+| 6 | `WO_MES_WOD` 只同步工单明细,**不能**代替本项。 |
+| 7 | `Mode=Local` 保持现有本库同步,本项不改 Local 路径。 |
+| 8 | 一工单多张 SM:先定更新对象(优先最新有效单),再改明细。 |
+
+建单路径(S1 下达)继续 **165 直写**;物料中途变更走 **Outbox**,不要把两条路径合成一次本地+165 双写。
+
+### 4. 跨模块影响(实施前须再确认)
+
+| 波及 | 可能影响 |
+|------|----------|
+| S1 | 评审改量若仍调「刷新领料」,须切到同一套 165 同步,避免又写本库 |
+| S5 | 入站后列表/数量口径变化;页面仍只读 |
+| S4 / WMS | 已领 `QtyRec` 行只能改未领部分或关未领多余行,不能覆盖已扫记录 |
+| WP9 | 须维持「一工单一张有效 SM」;补偿建领料仍不重建 |
+
+未单独立项、未确认范围前,**不改代码**。
+
+### 5. 建议验收(实现后)
+
+1. 下达工单 → 165 有且仅有一张有效 SM。  
+2. 在 S2 工单物料明细改数量 / 增行 / 删未领行并保存 → 165 对应 SM 明细与计划一致;已领行 `QtyRec` 不变。  
+3. S5 生产领料单在入站后能看到同一张单、同一套数量。  
+4. 本库 `Nbr*` 不因本次保存新增业务单(Mes 模式)。  
+5. 「MES 补偿建领料」对已有 SM 仍返回已存在,不另开第二张单。
+
+### 6. 明确不做(本项边界)
+
+- 不把 Mes 建单改回本库(测试「本库查不到 SM」不是缺陷)。  
+- 不在 S5 生产领料单上做改单。  
+- 不双写本库 + 165。  
+- 不在本文件展开客户 UAT 用例改写、KPI 九宫格口径等其它话题。
+
+---
+
+## 待办索引
+
+| ID | 标题 | 责任模块 | 状态 |
+|----|------|----------|------|
+| UAT-INT-001 | 工单物料中途变更后,领料单未回写 165 | **S2** | ✅ 已实现(1.0.402) |

+ 94 - 0
doc/plan/UAT测试/内部UAT测试问题记录表.md

@@ -0,0 +1,94 @@
+# 内部 UAT 测试问题记录表
+
+| 项 | 内容 |
+|----|------|
+| 目录用途 | 内部 UAT 过程中提出的口径/查数问题与结论(已解答、**不改程序**的记本表;要排期实现的记 [`内部UAT测试待办项.md`](./内部UAT测试待办项.md)) |
+| 登记日期 | 2026-08-28 |
+| 关联方案 | [`S5库存口径收敛(现存量与出入库)方案与执行任务书.md`](../S5库存口径收敛(现存量与出入库)方案与执行任务书.md) |
+
+---
+
+## 索引
+
+| ID | 问题摘要 | 责任模块 | 结论 | 是否改程序 |
+|----|----------|----------|------|------------|
+| UAT-Q-001 | 工单入库原先记在 `InvTransHist`,现在到哪查每次报工 | **S5** | 查 `mdp_std_inv_trans`(`rct-wo`);页面用进出存查询 | **否**(已落地) |
+| UAT-Q-002 | `ic_item_stock` 没有批次和入库时间,别处有没有 | **S5** | 批次在余额/流水/入库单;入库时间只在流水 | **否**(表职责如此,不补列) |
+
+---
+
+## UAT-Q-001 工单入库现在查哪里(原 `InvTransHist`)
+
+| 项 | 内容 |
+|----|------|
+| **责任模块** | **S5 仓储协同**(查询口径);写账仍在 165 WMS |
+| 提问日期 | 2026-08-27 |
+| 状态 | 已解答 |
+
+### 问题原文
+
+`InvTransHist` 之前记录入库数据。按现在的设计,如果要查询工单入库信息在哪里查?  
+以前工单完成一部分成品、平板上报工入库,会在这张表里有记录;多次报工就有多次记录,库存表只有总数。
+
+### 解答
+
+平板报工入库**仍写 165** `dopdemorq.InvTransHist`(`TransType='rct-wo'`,过程大致 `pr_WMS_SaveScanStorage`)。本库 `aidopdev.InvTransHist` 已冻结,只当历史归档,**不要再当现行入库账**。
+
+Ai-DOP 读模型是 **`mdp_std_inv_trans`**(165 流水近 12 个月入站,冷链增量,大约分钟级)。多次报工仍是多行。
+
+| 要查的 | 表 / 页面 | 怎么筛 |
+|--------|-----------|--------|
+| **每次报工一条流水** | `mdp_std_inv_trans` | `trans_type = 'rct-wo'`,再加 `work_ord` |
+| 页面 | S5 → 仓储 → **进出存查询** | 已读标准层,不再读本库 `InvTransHist` |
+| 权威实时对账 | 165 `InvTransHist` | 同上条件 |
+| 入库**单据**(比流水粗) | `mdp_std_production_receipt` / S5·S7 **生产入库单** | 165 `Nbr*` `Type='WOI'`;不是每次扫码一行 |
+
+流水字段对应:`qty_change` 数量,`trans_time` / `eff_date` 过账时间,`lot_serial` 批次,`work_ord` 工单。库存表只给当前结存,本来就不会按报工拆行。
+
+进出存、交付、排程里原先读本库 `InvTransHist` 的代码,已改读 `mdp_std_inv_trans`。**为回答本问不需要再改程序。**
+
+---
+
+## UAT-Q-002 批次和入库时间在不在别的表(`ic_item_stock`)
+
+| 项 | 内容 |
+|----|------|
+| **责任模块** | **S5 仓储协同**(库存口径);`ic_item_stock` 仍给齐套/占用用总数 |
+| 提问日期 | 2026-08-27 |
+| 状态 | 已解答 |
+
+### 问题原文
+
+`ic_item_stock` 这个库存表没有生产批次和入库时间,现在这个生产批次和入库时间在其他表有记录吗?
+
+### 解答
+
+**有,但不在 `ic_item_stock`。** 该表是物料级快照(`sqty` 一类总数),给齐套/占用用,不是入库台账。设计上不补批次、不补入库时刻。
+
+| 信息 | 在哪 | 粒度 |
+|------|------|------|
+| **生产批次** | `mdp_std_inventory.lot_serial`(现存量,来自 165 `LocationDetail.LotSerial`) | 物料 + 库位 + 批次 一行结存 |
+| **生产批次** | `mdp_std_inv_trans.lot_serial` | 每笔进出都有 |
+| **生产批次** | `mdp_std_production_receipt.lot_serial` | 入库单行上有 |
+| **入库时间** | `mdp_std_inv_trans.trans_time` / `eff_date` | **每次报工一条** |
+| **单据日期** | `mdp_std_production_receipt.receipt_date` | 单头日期,不是每次扫码时刻 |
+
+现存量页面:S5 **库存查询**。优先实时读 165 `LocationDetail`,165 挂了再回落 `mdp_std_inventory`(会标非实时)。这里有批次和当前数量,**没有「这笔是几点入的」**——时间只在流水表。
+
+对应关系:
+
+- 报了几次、几点入 → `mdp_std_inv_trans`(旧 `InvTransHist`)
+- 现在库里还有多少、哪个批 → `mdp_std_inventory` / 165 `LocationDetail`
+- `ic_item_stock` → 只当物料总数,不当入库记录
+
+**不要改 `ic_item_stock` 加列,也不要为这两问改程序。**
+
+---
+
+## 何时才要再动代码
+
+下面这些**不是**本表已解答内容,需另开待办后再改:
+
+- 进出存按工单查不到**刚扫**的入库(冷链滞后或超出近 12 个月窗口)
+- 仍有运行时代码读本库 `InvTransHist` 当现行账
+- 有人把 `ic_item_stock` 当入库明细来查并要求页面补批次/时间

+ 246 - 0
doc/plan/UAT测试/接手速览-旧DOP与165与新系统.md

@@ -0,0 +1,246 @@
+# 接手速览:旧 DOP · 165 · 新 Ai-DOP
+
+| 项 | 内容 |
+|----|------|
+| 用途 | 给**新会话 / 新大模型**一份可独立阅读的项目地图,并能直接接手 [`内部UAT测试待办项.md`](./内部UAT测试待办项.md) 的 **UAT-INT-001** |
+| 编写日期 | 2026-08-28 |
+| 仓库 | `D:\Projects\Ai-DOP\SourceCode\ZZYDOP`(本仓库 = **新 Ai-DOP**) |
+| 协作 | 改代码前先列清单等确认;「提交」= commit + push;升号只跟本次纳入的前后端文件。见仓库 `AGENTS.md` |
+
+**先读本文件,再打开待办项,不要从 2026-08-09 的总体方案当「当前代码事实」开改。** 那批文档里有已过期快照(源未启用、Outbox 0 行、`MdpDbPushExecutor` 不存在、165 单据全空),下列会标明「当时 / 现在」。
+
+---
+
+## 0. 三套东西不要混
+
+```text
+旧 DOP(计划侧老系统)     165 SQL Server(MES/WMS 执行库)     新 Ai-DOP(本仓库)
+ZZYDOP_OLD / DEMOMySql      dopdemorq @ 123.60.180.165:1433     MySQL aidopdev + Web/server
+曾与 MES 同库共管表          APP 只认这个库                       独立库 + 数据中台双向对接
+已不再作为运行前端           不可改结构/过程/加表                  S0–S9 重写,不是旧代码移植
+```
+
+| 名称 | 是什么 | 现在还跑什么 |
+|------|--------|----------------|
+| **旧 DOP** | 智造易 / DEMOMySql。计划员系统,历史上和 MES/WMS **同库分表** | 代码仓库保留对标;新前端**禁止**再调旧 `8087/api/business` |
+| **165 / `dopdemorq`** | MES、WMS、平板 APP 的**执行权威库** | APP 仍只读写这库。新系统按现有契约入站 + 字段级回写 |
+| **新 Ai-DOP** | 本仓库。Admin.NET + Vue,主库 MySQL `aidopdev` | 计划/协同/看板在本库;领料单权威在 165(`PickBill:Mode=Mes`) |
+
+常称「165」= 主机 `123.60.180.165`,也常被叫成「瑞奇演示库 / 智造易 DEV165」。同机还有 MySQL 3306、旧 Business 8087,**不是同一个库**。
+
+---
+
+## 1. 硬约束(写 165 或改领料前必守)
+
+来自 [`00-总体方案.md`](../旧DOP-MES-WMS/对接任务书/00-总体方案.md) §1,**现在仍然有效**:
+
+| # | 约束 | 对 UAT-INT-001 的含义 |
+|---|------|------------------------|
+| C1 | **MES/WMS 不可改**:不加表、不加触发器、不加作业、不改存储过程、不改表结构 | 只能按现有 `NbrMaster`/`NbrDetail` 契约 INSERT/UPDATE |
+| C2 | 新系统**零存储过程** | 旧过程语义用 C# 复刻,仍写 165 同一批表 |
+| C3 | MES/WMS 仍以 165 为执行真源 | 本库 `Nbr*` 不是权威;S5 领料单只读标准层 |
+| C4 | **无分布式事务** | 本库保存工单物料 + 165 改 SM **不能 XA**;中途变更走 Outbox,建单仍可 165 直写 |
+
+另外五条红线(任务书 README):
+
+1. 回写只允许**字段级**,禁止整行覆盖(会冲掉 WMS 的 `QtyRec` / 保管员)。
+2. 「库里有 `pr_SFM_*`」≠「还在被调用」。建领料应对齐旧 DOP C#,不要反编译死过程。
+3. 不要靠调快 MDP Job 求实时;流水/余额查询走标准层或 165 窄查询(WP8)。
+4. 密码、连接串口令**不得**写入仓库或本文。
+5. 改 S2 物料同步时,**不要**顺手改 S5 页面、不要双写本库+165。
+
+---
+
+## 2. 旧 DOP 系统
+
+权威速查:[`旧DOP系统环境速查.md`](../旧DOP-MES-WMS/旧DOP系统环境速查.md)。业务表:[`旧DOP-MES-WMS业务与读写表梳理.md`](../旧DOP-MES-WMS/旧DOP-MES-WMS业务与读写表梳理.md)。
+
+| 项 | 内容 |
+|----|------|
+| 代码 | 仓库外。远端 `http://123.60.180.165:4647/ZZYDOP/DEMOMySql` 分支 `dev`;本机常见 `C:\Users\skygu\OneDrive\Projects\ZZYDOP_OLD` |
+| 业务核心 | `MicroServices\Business\`;建领料看 `ResourceExamineAppService.AutoCreatePickBill` / `GenerateNbr` / `CreatePickBill` |
+| 历史主库 | 就是 165 的 `dopdemorq`(同库共管) |
+| 演进线 Default | 旧 Business 后来也连 MySQL `aidopdev`(与新系统**同机同库名**,查旧 SQL Server 表时不要当成 165) |
+| Mongo | `dopbase` 约 23 个 `mo_*`,缓存/中间态。新系统**不引入 Mongo** |
+| 旧 API | `http://123.60.180.165:8087/api/business/*` — 新前端不得恢复直连 |
+
+旧 DOP 和 MES 的顺畅感来自**同库实时可见**,不是「APP 操作完自动回写新系统」。新架构把这份实时拆成:冷链入站 + WP8 热回读 + 出站 Outbox。
+
+旧侧计划写、MES/WMS 读的共享表(新系统必须回写的那批)包括:
+
+`WorkOrdMaster/Detail/Routing`、`PurOrd*`、**`NbrMaster`/`NbrDetail`(领料 SM)**、`PeriodSequenceDet`、`srm_polist_ds`、`ScheduleResultOpMaster`、`rf_serialnumber`,以及 WP7 的基础数据 / `MobileTask` / `EmpWorkDutyMaster`(保管员,建领料前置)。
+
+---
+
+## 3. 165 服务器与 `dopdemorq`
+
+| 项 | 值 |
+|----|-----|
+| 引擎 | SQL Server `123.60.180.165:1433` |
+| **对接库** | **`dopdemorq`**(同实例 `dopdemorb` / `dopextra` / `hu_prod` 无权限,不要连) |
+| 账号角色 | 文档写 `dopsa`;密码带外。Cursor MCP:`user-sqlserver-dopdemorq`(只读探查) |
+| Domain | 联调/演示常用 **`8010`**;`GeneralizedCodeMaster.SystemConfig` 实测 `CompanyCode='TJRQ'` |
+| 单号 | `NbrControl` / `NbrDayInfo` / `NbrDateFormat`;领料类型 **`sm`** → 号如 `SM20260826001` |
+| APP | 平板 MES/WMS 只认 165。手册在本机 `...\方老师APP\操作手册`(仓库外) |
+| 规模(2026-05 量级) | ~645 表;过程 283;`InvTransHist` 约百万行 |
+
+**谁写什么(和本待办相关):**
+
+| 表 | 谁写 | 说明 |
+|----|------|------|
+| `NbrMaster` / `NbrDetail` `Type='SM'` | 计划侧建单/改量;WMS 写已领、状态 | 领料单权威。不变量:**一工单最多一张有效 SM**(`IFNULL(TransType,'')=''` 且有效) |
+| `WorkOrdMaster/Detail/Routing` | 计划侧 UPSERT;MES 写完工/投产 | 工单层。`WO_MES_WOD` **不能**代替改 SM |
+| `InvTransHist` / `LocationDetail` | **只有 165/WMS 过程** | 平板入库、库存。Ai-DOP 只入站回读 |
+| `EmpWorkDutyMaster` | DOP/Ai-DOP 维护 | 保管员;建单时解析 `User1`/`User2`,**之后禁止再改这两列** |
+
+2026-08-09 盘点时单据表曾被清空。UAT 之后 **165 已有业务单**(例如工单 `M500000001` 上的 `SM20260826001`、`SM20260826002`)。不要再用「单据全 0」当前提。
+
+字段级谁可写:[`附-字段共管矩阵.md`](../旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md) **§5 `NbrDetail`、§13.1 建领料通道**。改 SM 时:
+
+- **可以**:`QtyOrd`、`CurrQtyOpened`、必要时把未领行 `Status` 清空;关未领多余行。
+- **禁止**:`QtyRec`、`QtyFrom`/`QtyTo`、明细 `User1`/`User2`、`IsConfirm`/`SignTime`。
+- 已领行:只能改「未领差额」或关未领多余料,**不能覆盖已扫数量**。
+
+---
+
+## 4. 新 Ai-DOP 系统
+
+### 4.1 模块(S0–S9)
+
+| 模块 | 名称 | 和本待办 |
+|------|------|----------|
+| S0 | 运营建模 | 主数据。不改 |
+| **S1** | 产销协同 | 工单下达 **首次建 SM**(已按 WP9 落地)。评审改量可能误走本库刷新领料 |
+| **S2** | 制造协同 | **UAT-INT-001 主责**。工单物料明细 `FUNC-S2-011` |
+| S3 | 供应协同 | 不改 |
+| S4 | 采购执行 | 已领与 WMS 相关,回写时勿碰 `QtyRec` |
+| **S5** | 仓储协同 | 生产领料单**只读** `mdp_std_production_issue`;库存见问题记录表 |
+| S6–S7 | 生产执行 / 成品仓储 | 报工入库在 165,Ai-DOP 读 `mdp_std_inv_trans` |
+| S8–S9 | 异常 / 指标看板 | 不改。FUNC 跨模块项暂挂 S9 扩展段 |
+
+前端:`Web/`(Vue)。后端插件:`server/Plugins/Admin.NET.Plugin.AiDOP/`。  
+菜单编号:`Web/src/constants/aidopFuncCodes.ts`;种子 `SysMenuSeedData.cs` 的 Title **不要**写 `FUNC-…`。
+
+### 4.2 主库与中台
+
+| 项 | 内容 |
+|----|------|
+| 主库 | MySQL `123.60.180.165:3306` / **`aidopdev`**。MCP:`user-mysql-aidopdev` |
+| 分层 | 业务表 → `mdp_stg_*` → `mdp_std_*` → `dwd_*` → `ado_s9_kpi_*` |
+| 165 源码 | `mdp_source.source_code = 'DOPDEMORQ_SQLSERVER'`(现网已启用;08-09 文档写 `status=0` 已过期) |
+| 出站 | `mdp_outbox` + `MdpDbPushExecutor`(WP3 已落地)。`status`:`0` 待推、`1` 成功、`2` 死信 |
+| 热回读 | WP8 `MdpHotWatchService`。建领料成功后 `EnrollPickBillAsync`,S5 才能较快看到 SM |
+
+库存读模型(已落地,见 [`内部UAT测试问题记录表.md`](./内部UAT测试问题记录表.md)):
+
+- 流水 / 每次报工:`mdp_std_inv_trans`(`rct-wo`),页面「进出存查询」
+- 现存量 + 批次:`mdp_std_inventory` / 165 `LocationDetail`
+- `ic_item_stock`:物料总数,**无批次、无入库时间**,不要当入库账
+
+本库 `aidopdev.InvTransHist` / `LocationDetail` / `InvMaster`:**冻结归档**,禁止当现行账。
+
+### 4.3 领料:现在已经怎样(WP9 已定)
+
+配置:`server/Admin.NET.Application/Configuration/App.json` → `AiDOP:PickBill:Mode = "Mes"`。
+
+```text
+S1 工单下达
+  → IPickBillCreator / PickBillCreatorFacade
+      Mes → CreatePickBillService.InsertNbrOn165Async   ← 165 直写 SM(权威)
+      Local → WorkOrderPickBillService                   ← 仅降级
+  → Outbox PICK_WOM_UPSERT(工单头 Status/Batch)
+  → EnrollPickBill → HotWatch → mdp_std_production_issue
+```
+
+| 事实 | 说明 |
+|------|------|
+| 权威库 | **165**(D7-A)。测试在本库按 `tenant_id` 查不到新 SM **不是 bug** |
+| 取号 | 165 `sm` 计数器(WP2),不要用本库 `NbrControl` 发 165 号 |
+| 补偿按钮 | 「MES 补偿建领料」已存在则返回原号,**不重建明细** |
+| S5 列表 | 只读标准层,**不是改单入口** |
+| 缺口 | ~~工单物料中途变更仍写本库 `Nbr*`~~ → **UAT-INT-001 已实现**(server `1.0.402`,Outbox `PICK_NBR_SYNC`) |
+
+已有 Outbox 类型(可对标,不要发明第二套通道):`WO_MES_WOM/WOD/WOR`、`PICK_WOM_UPSERT`、`S2_PSD_*`。待办建议新增 **`PICK_NBR_SYNC`**,幂等键 `pick|{domain}|{workOrd}|sm-sync`。
+
+---
+
+## 5. UAT 租户(查数时不要串套)
+
+造数总入口:[`UAT测试数据准备/README.md`](../UAT测试数据准备/README.md)。
+
+| 套 | 租户编码 | tenant_id | 用途 |
+|----|----------|-----------|------|
+| **A 客户 UAT** | `UATTEST_CHL` | `838257186181189` | 客户用例;账号 `UATAdminA` / `UATPlanA` 等 |
+| B 从零点通 | `UATTEST_CHLB` | `838257212780613` | 不预置完整单据 |
+| Demo | `UATDEMO` | `838257237606469` | 演示,**不是 UAT** |
+
+A 套业务键前缀常见 `UATA-A0x-`。齐套库存配置里还有历史租户 `797403760988229` + Domain `8010`(`AiDOP:Stock`),查 165 单据用 Domain,查本库业务用当前 JWT 租户。
+
+待办样例(165,**一工单两张 SM,违规**):工单 `M500000001` → `SM20260826001`、`SM20260826002`。实现时先定「改最新一张未关闭单」。
+
+---
+
+## 6. 接手 UAT-INT-001:代码落点
+
+任务正文:[`内部UAT测试待办项.md`](./内部UAT测试待办项.md)。方案已对齐,**未编码**。未再确认范围前不要开写。
+
+| 角色 | 路径 |
+|------|------|
+| 物料保存/删行(现写本库 Nbr) | `Production/WorkOrderSchedulingService.cs` → `CreateOrUpdateNbrDetailForMaterial` |
+| 排程动作同步领料 | `Production/ProductionSchedulingActionService.cs` → `SyncPickingListDetailsAsync` |
+| S1 评审刷新领料 | `Order/OrderReviewOrchestrationService.cs` → `UpdatePickingListForActiveWorkOrderAsync` |
+| 165 建单(对标直写,本项不重做) | `DataPlatform/Wms/CreatePickBillService.cs` |
+| 开关 | `WorkOrder/PickBillCreatorFacade.cs`、`Option/AidopPickBillOptions.cs` |
+| 工单入 165(不是 SM) | `DataPlatform/Wms/WorkOrdMesPushService.cs` |
+| 出站 / 热回读 | `DataPlatform/Executors/`、`DataPlatform/HotWatch/MdpHotWatchService.cs` |
+| S5 领料只读 | `mdp_std_production_issue` / `ProductionIssueMdpSyncService` |
+
+**目标形态(待办已定,摘录):**
+
+1. 用户先保存本库 `WorkOrdDetail`(计划变更权威仍在本库)。
+2. Mes:**停止**写本库 `Nbr*`;入队 `PICK_NBR_SYNC`。
+3. 执行器在 **165 本地事务**内改那一张有效 SM(INSERT 缺料 / UPDATE `QtyOrd` / 关未领多余行),回填 `RecID`。
+4. 成功后 `EnrollPickBillAsync`。Local 模式保持现逻辑。
+5. 不双写、不改 S5、不重建补偿建单。
+
+跨模块:S1 评审刷新须切到同一套 165 同步,否则又写本库。实施前按协作规则写出影响并等确认。
+
+---
+
+## 7. 建议阅读顺序(约 30–40 分钟)
+
+1. 本文 + [`内部UAT测试待办项.md`](./内部UAT测试待办项.md)
+2. [`WP9-领料建单双路径统一.md`](../旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md) §1.1、§2 D7–D9、§3
+3. [`附-字段共管矩阵.md`](../旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md) §0.2、§5、§13.1
+4. [`00-总体方案.md`](../旧DOP-MES-WMS/对接任务书/00-总体方案.md) **只读 §1 硬约束**;§2 环境数字当历史
+5. [`00B-执行须知`](../旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md) §2 落点、§5 禁止事项(其中「`MdpDbPushExecutor` 不存在」已过期)
+6. 代码:`CreatePickBillService`(建单怎么写 165)→ `WorkOrderSchedulingService.CreateOrUpdateNbrDetailForMaterial`(现在错写本库)
+7. 口径题不必重做:[`内部UAT测试问题记录表.md`](./内部UAT测试问题记录表.md)
+
+专题索引:[`旧DOP-MES-WMS/README.md`](../旧DOP-MES-WMS/README.md)、[`对接任务书/README.md`](../旧DOP-MES-WMS/对接任务书/README.md)、[`执行进度-2026-08-09.md`](../旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md)(WP9 S1–S5 已落地清单)。
+
+---
+
+## 8. 明确不要做
+
+- 不要把 Mes 建单改回本库,也不要为「本库查不到 SM」提 bug。
+- 不要在 S5 生产领料单上做改单。
+- 不要用 `WO_MES_WOD` 冒充领料同步。
+- 不要写 `QtyRec` / `User1` / `User2`。
+- 不要双写本库 + 165,不要上 XA。
+- 不要改客户 UAT 用例、不要动 KPI 九宫格、不要给 `ic_item_stock` 加批次列。
+- 不要把 08-09「源未启用 / 单据 0 行 / 无 DbPush」写成当前事实。
+- 未列出改动清单并得到用户确认前,**不改代码**。
+
+---
+
+## 9. 复制给下一会话的提示词
+
+```text
+仓库:D:/Projects/Ai-DOP/SourceCode/ZZYDOP
+先读:doc/plan/UAT测试/接手速览-旧DOP与165与新系统.md
+再读:doc/plan/UAT测试/内部UAT测试待办项.md(UAT-INT-001)
+任务:S2 工单物料中途变更后,把领料单明细回写 165(Outbox PICK_NBR_SYNC),不要重做 S1 建单。
+硬约束:MES/WMS 不可改;无 XA;禁止写 QtyRec/User1/User2;Mes 模式不要再写本库 Nbr*。
+动手前先列改动文件清单,等我确认后再改。
+```

+ 1 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md

@@ -322,6 +322,7 @@
 | 目标 | 通道 | 幂等键 |
 |------|------|--------|
 | 165 `NbrMaster` / `NbrDetail` | **165 直写**,包在 165 本地事务内(需 identity `RecID` 回填明细) | 无(靠建单前的三分类探查去重) |
+| 165 `NbrMaster` / `NbrDetail` 物料中途变更 | **Outbox** `PICK_NBR_SYNC`(`PickBillNbrSyncService`,Mes 模式;最新一张有效 SM) | `pick\|{domain}\|{workOrd}\|sm-sync` |
 | 165 `WorkOrdMaster.Status`/`Batch` | **Outbox UPSERT**(`action_code=PICK_WOM_UPSERT`) | `pick\|{domain}\|{workOrd}\|wom` |
 | 165 里程碑 `WorkOrdRouting.Status` | **Outbox UPDATE,逐工序一条**(原为裸直写,WP10 F8 改造)。keys = `{Domain, WorkOrd, OP}`(行须已由 §13.0 推入) | `pick\|{domain}\|{workOrd}\|{op}\|wor` |
 | 本库 `WorkOrdMaster` / `WorkOrdRouting` | MySQL 本地事务,与 Outbox 入队同事务 | — |

+ 46 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/PickBillNbrSyncRulesTests.cs

@@ -0,0 +1,46 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class PickBillNbrSyncRulesTests
+{
+    [Fact]
+    public void Unpicked_qty_change_updates()
+    {
+        var a = PickBillNbrSyncRules.DecideExisting(qtyRec: 0, newQty: 12, stillRequired: true);
+        Assert.Equal(PickBillNbrSyncRules.LineAction.UpdateQty, a);
+    }
+
+    [Fact]
+    public void Picked_less_than_new_qty_updates()
+    {
+        var a = PickBillNbrSyncRules.DecideExisting(qtyRec: 3, newQty: 10, stillRequired: true);
+        Assert.Equal(PickBillNbrSyncRules.LineAction.UpdateQty, a);
+    }
+
+    [Fact]
+    public void Picked_not_less_than_new_qty_closes()
+    {
+        var a = PickBillNbrSyncRules.DecideExisting(qtyRec: 8, newQty: 8, stillRequired: true);
+        Assert.Equal(PickBillNbrSyncRules.LineAction.Close, a);
+    }
+
+    [Fact]
+    public void Removed_from_plan_closes()
+    {
+        var a = PickBillNbrSyncRules.DecideExisting(qtyRec: 0, newQty: 0, stillRequired: false);
+        Assert.Equal(PickBillNbrSyncRules.LineAction.Close, a);
+    }
+
+    [Theory]
+    [InlineData(null, "8010")]
+    [InlineData("", "8010")]
+    [InlineData("8010", "8010")]
+    [InlineData("838257186181189", "8010")]
+    [InlineData("2410", "2410")]
+    public void ResolveMesDomain_falls_back_when_blank_or_too_long(string? domain, string expected)
+    {
+        Assert.Equal(expected, PickBillNbrSyncService.ResolveMesDomain(domain));
+    }
+}

+ 9 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpTargetPushDispatcher.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Microsoft.Extensions.Logging;
 
@@ -29,6 +30,7 @@ public sealed class MdpTargetPushDispatcher : ITransient
     private readonly MdpApiPushExecutor _api;
     private readonly MdpDbPushExecutor _dbPush;
     private readonly MdpHotWatchService _hotWatch;
+    private readonly PickBillNbrSyncService _pickNbrSync;
     private readonly ILogger _logger;
 
     public MdpTargetPushDispatcher(
@@ -36,12 +38,14 @@ public sealed class MdpTargetPushDispatcher : ITransient
         MdpApiPushExecutor api,
         MdpDbPushExecutor dbPush,
         MdpHotWatchService hotWatch,
+        PickBillNbrSyncService pickNbrSync,
         ILoggerFactory loggerFactory)
     {
         _db = db;
         _api = api;
         _dbPush = dbPush;
         _hotWatch = hotWatch;
+        _pickNbrSync = pickNbrSync;
         _logger = loggerFactory.CreateLogger(nameof(MdpTargetPushDispatcher));
     }
 
@@ -94,9 +98,11 @@ public sealed class MdpTargetPushDispatcher : ITransient
                 }
 
                 _logger.LogInformation(
-                    "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
-                    item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
-                var result = await executor.PushAsync(source, item, cancellationToken);
+                    "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor} action={Action}",
+                    item.Id, source.SourceCode, source.SourceType, executor!.SupportedType, item.ActionCode);
+                var result = string.Equals(item.ActionCode, PickBillNbrSyncService.ActionCode, StringComparison.OrdinalIgnoreCase)
+                    ? await _pickNbrSync.ApplyFromOutboxAsync(item, cancellationToken)
+                    : await executor.PushAsync(source, item, cancellationToken);
                 if (result.Success)
                 {
                     var marked = await MarkAsync(

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs

@@ -86,7 +86,7 @@ public sealed class MdpHotWatchService : ITransient
         var action = (item.ActionCode ?? "").Trim().ToUpperInvariant();
         var isWo =
             action.StartsWith("WO_MES_", StringComparison.Ordinal)
-            || action is "PICK_WOM_UPSERT" or "PICK_WOR_STATUS";
+            || action is "PICK_WOM_UPSERT" or "PICK_WOR_STATUS" or "PICK_NBR_SYNC";
         if (!isWo) return Task.CompletedTask;
 
         if (!TryParseWoIdem(item.IdemKey, out var domain, out var workOrd))

+ 26 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/PickBillNbrSyncRules.cs

@@ -0,0 +1,26 @@
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+
+/// <summary>
+/// UAT-INT-001 · 工单物料 vs 165 SM 明细的行级决策(不写 QtyRec)。
+/// </summary>
+public static class PickBillNbrSyncRules
+{
+    public enum LineAction
+    {
+        UpdateQty,
+        Close,
+        Insert
+    }
+
+    /// <summary>
+    /// 已有 SM 行:工单不再需要则关行;已领且新需求不超过已领则关行;否则只改 QtyOrd。
+    /// </summary>
+    public static LineAction DecideExisting(decimal qtyRec, decimal newQty, bool stillRequired)
+    {
+        if (!stillRequired)
+            return LineAction.Close;
+        if (qtyRec > 0 && newQty <= qtyRec)
+            return LineAction.Close;
+        return LineAction.UpdateQty;
+    }
+}

+ 408 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/PickBillNbrSyncService.cs

@@ -0,0 +1,408 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+
+/// <summary>
+/// UAT-INT-001 · Mes 模式:工单物料变更后入队 Outbox,推送时在 165 本地事务内同步一张有效 SM。
+/// Local 模式不入队,由调用方继续写本库 Nbr*。
+/// </summary>
+public sealed class PickBillNbrSyncService : ITransient
+{
+    public const string ActionCode = "PICK_NBR_SYNC";
+    public const string TargetSource = CreatePickBillService.TargetSource;
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly MdpOutboxEnqueueService _enqueue;
+    private readonly MdpHotWatchService _hotWatch;
+    private readonly AidopPickBillOptions _opt;
+    private readonly ILogger<PickBillNbrSyncService> _logger;
+
+    public PickBillNbrSyncService(
+        ISqlSugarClient db,
+        MdpSourceScopeFactory scopeFactory,
+        MdpOutboxEnqueueService enqueue,
+        MdpHotWatchService hotWatch,
+        IOptions<AidopPickBillOptions> opt,
+        ILogger<PickBillNbrSyncService> logger)
+    {
+        _db = db;
+        _scopeFactory = scopeFactory;
+        _enqueue = enqueue;
+        _hotWatch = hotWatch;
+        _opt = opt.Value;
+        _logger = logger;
+    }
+
+    public bool IsMes => _opt.IsMes;
+
+    /// <summary>
+    /// Mes 且工单已下达/投产/暂停时入队(同键刷新 payload)。Local 或未下达返回 false。
+    /// </summary>
+    public async Task<bool> TryEnqueueAfterPlanChangeAsync(
+        long tenantId, string workOrd, string? domain, string account, CancellationToken ct = default)
+    {
+        if (!_opt.IsMes)
+            return false;
+
+        workOrd = (workOrd ?? "").Trim();
+        if (string.IsNullOrWhiteSpace(workOrd))
+            return false;
+
+        var status = await _db.Ado.GetStringAsync(
+            """
+            SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (status is not ("r" or "w" or "s"))
+            return false;
+
+        var mesDomain = ResolveMesDomain(domain);
+        var idem = $"pick|{mesDomain}|{workOrd}|sm-sync";
+        if (idem.Length > 200)
+            idem = idem[..200];
+
+        var item = new MdpOutbox
+        {
+            TenantId = tenantId,
+            TargetSourceCode = TargetSource,
+            ActionCode = ActionCode,
+            IdemKey = idem,
+            PayloadJson = JsonSerializer.Serialize(new PickNbrSyncPayload
+            {
+                Domain = mesDomain,
+                WorkOrd = workOrd,
+                Account = string.IsNullOrWhiteSpace(account) ? "aidop" : account.Trim()
+            })
+        };
+
+        var ok = await _enqueue.TryEnqueueOrRefreshAsync(item, ct);
+        _logger.LogInformation(
+            "[PICK_NBR_SYNC] enqueue tenant={Tenant} wo={Wo} domain={Domain} refreshed={Ok}",
+            tenantId, workOrd, mesDomain, ok);
+        return true;
+    }
+
+    public async Task<MdpPushResult> ApplyFromOutboxAsync(MdpOutbox item, CancellationToken ct)
+    {
+        PickNbrSyncPayload payload;
+        try
+        {
+            payload = JsonSerializer.Deserialize<PickNbrSyncPayload>(item.PayloadJson ?? "")
+                      ?? throw new InvalidOperationException("payload 为空");
+        }
+        catch (Exception ex)
+        {
+            return MdpPushResult.Fail($"PAYLOAD_INVALID: {ex.Message}");
+        }
+
+        var domain = ResolveMesDomain(payload.Domain);
+        var workOrd = (payload.WorkOrd ?? "").Trim();
+        var account = Trunc(payload.Account, 24);
+        if (string.IsNullOrWhiteSpace(workOrd))
+            return MdpPushResult.Fail("PAYLOAD_INVALID: workOrd 为空");
+
+        var plan = await LoadPlanLinesAsync(item.TenantId, workOrd);
+        ISqlSugarClient ss;
+        try
+        {
+            ss = await _scopeFactory.GetScopeAsync(TargetSource, ct);
+        }
+        catch (Exception ex)
+        {
+            return MdpPushResult.Fail($"MES_UNREACHABLE: {ex.Message}");
+        }
+
+        try
+        {
+            var result = await ApplyOn165Async(ss, item.TenantId, domain, workOrd, account, plan, ct);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[PICK_NBR_SYNC] apply failed wo={Wo} domain={Domain}", workOrd, domain);
+            return MdpPushResult.Fail($"MES_WRITE_FAILED: {ex.Message}");
+        }
+    }
+
+    private async Task<MdpPushResult> ApplyOn165Async(
+        ISqlSugarClient ss,
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        List<PlanLine> plan,
+        CancellationToken ct)
+    {
+        var sm = (await ss.Ado.SqlQueryAsync<SmHead>(
+            """
+            SELECT TOP 1 RecID, Nbr, Domain
+            FROM NbrMaster
+            WHERE Domain = @d AND WorkOrd = @w AND Type = 'SM'
+              AND ISNULL(TransType, '') = ''
+              AND ISNULL(IsActive, 1) = 1
+            ORDER BY RecID DESC
+            """,
+            new SugarParameter("@d", domain),
+            new SugarParameter("@w", workOrd))).FirstOrDefault();
+
+        if (sm == null || string.IsNullOrWhiteSpace(sm.Nbr))
+        {
+            return MdpPushResult.Skip(JsonSerializer.Serialize(new
+            {
+                idempotent = true,
+                reason = "no_sm",
+                workOrd,
+                domain
+            }));
+        }
+
+        var existing = await ss.Ado.SqlQueryAsync<SmLine>(
+            """
+            SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line, Status
+            FROM NbrDetail
+            WHERE NbrRecID = @rid AND Type = 'SM'
+              AND ISNULL(IsActive, 1) = 1
+            """,
+            new SugarParameter("@rid", sm.RecID));
+
+        var planMap = plan
+            .Where(p => !string.IsNullOrWhiteSpace(p.ItemNum))
+            .ToDictionary(p => p.ItemNum.Trim(), p => p, StringComparer.OrdinalIgnoreCase);
+        var existingMap = existing
+            .ToDictionary(x => (x.ItemNum ?? "").Trim(), x => x, StringComparer.OrdinalIgnoreCase);
+
+        var now = DateTime.Now;
+        var updated = 0;
+        var closed = 0;
+        var inserted = 0;
+
+        await ss.Ado.BeginTranAsync();
+        try
+        {
+            foreach (var line in existing)
+            {
+                var key = (line.ItemNum ?? "").Trim();
+                var still = planMap.TryGetValue(key, out var p);
+                var newQty = still ? p!.QtyRequired : 0m;
+                var action = PickBillNbrSyncRules.DecideExisting(line.QtyRec, newQty, still);
+
+                if (action == PickBillNbrSyncRules.LineAction.Close)
+                {
+                    await ss.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE NbrDetail
+                           SET Status = 'C',
+                               UpdateUser = @u,
+                               UpdateTime = @t
+                         WHERE RecID = @id
+                        """,
+                        new SugarParameter("@u", account),
+                        new SugarParameter("@t", now),
+                        new SugarParameter("@id", line.RecID));
+                    closed++;
+                    continue;
+                }
+
+                if (action == PickBillNbrSyncRules.LineAction.UpdateQty
+                    && (line.QtyOrd != newQty || !string.Equals(line.Status ?? "", "", StringComparison.Ordinal)))
+                {
+                    await ss.Ado.ExecuteCommandAsync(
+                        """
+                        UPDATE NbrDetail
+                           SET QtyOrd = @q,
+                               CurrQtyOpened = @q,
+                               UM = @um,
+                               ItemName = @name,
+                               Status = '',
+                               UpdateUser = @u,
+                               UpdateTime = @t
+                         WHERE RecID = @id
+                        """,
+                        new SugarParameter("@q", newQty),
+                        new SugarParameter("@um", Trunc(p!.Unit, 8)),
+                        new SugarParameter("@name", Trunc(p.ItemName, 1000)),
+                        new SugarParameter("@u", account),
+                        new SugarParameter("@t", now),
+                        new SugarParameter("@id", line.RecID));
+                    updated++;
+                }
+            }
+
+            short nextLine = existing.Count > 0 ? (short)(existing.Max(x => x.Line) + 1) : (short)1;
+            foreach (var p in plan)
+            {
+                var key = p.ItemNum.Trim();
+                if (existingMap.ContainsKey(key))
+                    continue;
+
+                await ss.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO NbrDetail
+                        (Domain, Type, Nbr, Line, ItemNum, Dimension1, Dimension2,
+                         LocationFrom, LocationTo, QtyFrom, QtyTo, UM, [Print], Status, LotSerial,
+                         WorkOrd, QtyOrd, QtyRec, Address, BusinessID, CreateUser, UpdateUser,
+                         CreateTime, UpdateTime, IsActive, IsConfirm, QtyCache, CurrQtyOpened,
+                         IsChanged, NbrRecID, OrdNbr, ItemName, ERPfld1, ERPfld2, OrdLine,
+                         IsGP12Demand, IsGP12Checked, Material, SeqID)
+                    VALUES
+                        (@Domain, 'SM', @Nbr, @Line, @ItemNum, '', '',
+                         @LocationFrom, @LocationTo, 0, 0, @UM, 0, '', @LotSerial,
+                         @WorkOrd, @QtyOrd, 0, '', 0, @User, @User,
+                         @Now, @Now, 1, 0, 0, @QtyOrd,
+                         1, @NbrRecID, '', @ItemName, '', '', 0,
+                         0, 0, 0, 0)
+                    """,
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@Nbr", sm.Nbr),
+                    new SugarParameter("@Line", nextLine++),
+                    new SugarParameter("@ItemNum", Trunc(p.ItemNum, 24)),
+                    new SugarParameter("@LocationFrom", Trunc(p.LocationFrom, 8)),
+                    new SugarParameter("@LocationTo", Trunc(p.LocationTo, 8)),
+                    new SugarParameter("@UM", Trunc(p.Unit, 8)),
+                    new SugarParameter("@LotSerial", p.LotSerial ?? ""),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@QtyOrd", p.QtyRequired),
+                    new SugarParameter("@User", account),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@NbrRecID", sm.RecID),
+                    new SugarParameter("@ItemName", Trunc(p.ItemName, 1000)));
+                inserted++;
+            }
+
+            await ss.Ado.ExecuteCommandAsync(
+                """
+                UPDATE NbrMaster
+                   SET QtyOrd = (
+                           SELECT ISNULL(SUM(QtyOrd), 0)
+                             FROM NbrDetail
+                            WHERE NbrRecID = @rid AND Type = 'SM'
+                              AND ISNULL(IsActive, 1) = 1
+                              AND ISNULL(Status, '') <> 'C'
+                       ),
+                       UpdateUser = @u,
+                       UpdateTime = @t
+                 WHERE RecID = @rid
+                """,
+                new SugarParameter("@rid", sm.RecID),
+                new SugarParameter("@u", account),
+                new SugarParameter("@t", now));
+
+            await ss.Ado.CommitTranAsync();
+        }
+        catch
+        {
+            await ss.Ado.RollbackTranAsync();
+            throw;
+        }
+
+        try
+        {
+            await _hotWatch.EnrollPickBillAsync(domain, sm.Nbr!, workOrd, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[PICK_NBR_SYNC] hot-watch enroll failed nbr={Nbr} wo={Wo}", sm.Nbr, workOrd);
+        }
+
+        var response = JsonSerializer.Serialize(new
+        {
+            nbr = sm.Nbr,
+            workOrd,
+            domain,
+            updated,
+            closed,
+            inserted
+        });
+        _logger.LogInformation(
+            "[PICK_NBR_SYNC] applied nbr={Nbr} wo={Wo} upd={U} close={C} ins={I}",
+            sm.Nbr, workOrd, updated, closed, inserted);
+        return MdpPushResult.Ok(updated + closed + inserted, response);
+    }
+
+    private async Task<List<PlanLine>> LoadPlanLinesAsync(long tenantId, string workOrd)
+    {
+        return await _db.Ado.SqlQueryAsync<PlanLine>(
+            """
+            SELECT
+                TRIM(d.ItemNum) AS ItemNum,
+                SUM(d.QtyRequired) AS QtyRequired,
+                MAX(IFNULL(d.UM, im.Um)) AS Unit,
+                MAX(im.Descr) AS ItemName,
+                MAX(IFNULL(NULLIF(TRIM(d.Location), ''), IFNULL(im.Location, ''))) AS LocationFrom,
+                MAX(IFNULL(m.Location, '')) AS LocationTo,
+                MAX(IFNULL(d.LotSerial, '')) AS LotSerial
+            FROM WorkOrdDetail d
+            LEFT JOIN WorkOrdMaster m
+              ON m.WorkOrd = d.WorkOrd AND m.tenant_id = d.tenant_id
+            LEFT JOIN ItemMaster im
+              ON im.ItemNum = d.ItemNum AND im.tenant_id = d.tenant_id
+            WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1
+            GROUP BY TRIM(d.ItemNum)
+            HAVING SUM(d.QtyRequired) > 0
+            ORDER BY TRIM(d.ItemNum)
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+    }
+
+    /// <summary>165 Domain 最长 8 位;本库若把 tenant_id 写进 Domain 则回落到 8010。</summary>
+    public static string ResolveMesDomain(string? domain)
+    {
+        var d = (domain ?? "").Trim();
+        if (string.IsNullOrWhiteSpace(d) || d.Length > 8)
+            return "8010";
+        return d;
+    }
+
+    private static string Trunc(string? s, int max)
+    {
+        s ??= "";
+        return s.Length <= max ? s : s[..max];
+    }
+
+    private sealed class PickNbrSyncPayload
+    {
+        public string Domain { get; set; } = "8010";
+        public string WorkOrd { get; set; } = "";
+        public string Account { get; set; } = "aidop";
+    }
+
+    private sealed class PlanLine
+    {
+        public string ItemNum { get; set; } = "";
+        public decimal QtyRequired { get; set; }
+        public string? Unit { get; set; }
+        public string? ItemName { get; set; }
+        public string? LocationFrom { get; set; }
+        public string? LocationTo { get; set; }
+        public string? LotSerial { get; set; }
+    }
+
+    private sealed class SmHead
+    {
+        public int RecID { get; set; }
+        public string? Nbr { get; set; }
+        public string? Domain { get; set; }
+    }
+
+    private sealed class SmLine
+    {
+        public int RecID { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal QtyOrd { get; set; }
+        public decimal QtyRec { get; set; }
+        public decimal CurrQtyOpened { get; set; }
+        public short Line { get; set; }
+        public string? Status { get; set; }
+    }
+}

+ 18 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderReviewOrchestrationService.cs

@@ -34,6 +34,7 @@ public class OrderReviewOrchestrationService : ITransient
     private readonly WorkOrdMesPushService _workOrdMesPush;
     private readonly MaterialRequirementCalculator _calculator;
     private readonly WorkOrderKittingCheckService _kittingCheckService;
+    private readonly PickBillNbrSyncService _pickNbrSync;
 
     public OrderReviewOrchestrationService(
         ISqlSugarClient db,
@@ -48,7 +49,8 @@ public class OrderReviewOrchestrationService : ITransient
         ProcurementPipelineService pipeline,
         WorkOrdMesPushService workOrdMesPush,
         MaterialRequirementCalculator calculator,
-        WorkOrderKittingCheckService kittingCheckService)
+        WorkOrderKittingCheckService kittingCheckService,
+        PickBillNbrSyncService pickNbrSync)
     {
         _db = db;
         _userManager = userManager;
@@ -63,6 +65,7 @@ public class OrderReviewOrchestrationService : ITransient
         _workOrdMesPush = workOrdMesPush;
         _calculator = calculator;
         _kittingCheckService = kittingCheckService;
+        _pickNbrSync = pickNbrSync;
     }
 
     public Task<SeOrderReviewExecuteResult> ReviewAsync(IReadOnlyList<long> orderIds) =>
@@ -787,6 +790,20 @@ public class OrderReviewOrchestrationService : ITransient
         if (status is not ("r" or "w" or "s"))
             return;
 
+        var woDomain = await _db.Ado.GetStringAsync(
+            """
+            SELECT IFNULL(`Domain`, '') FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+        if (await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, woDomain, account))
+        {
+            warnings.Add($"工单 {workOrd} 领料单已入队回写 165");
+            return;
+        }
+
         // 查找领料单主记录
         var nbrRows = await _db.Ado.SqlQueryAsync<NbrMasterRow>(
             """

+ 18 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ProductionSchedulingActionService.cs

@@ -1,5 +1,6 @@
 using System.Globalization;
 using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.AiDOP.Order;
 using Admin.NET.Plugin.AiDOP.WorkOrder;
@@ -21,6 +22,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
     private readonly WorkOrderRoutingSyncService _routingSync;
     private readonly AidopActionRunLogWriter _runLog;
     private readonly MdpRefreshDispatcher _dispatcher;
+    private readonly PickBillNbrSyncService _pickNbrSync;
     private readonly ILogger<ProductionSchedulingActionService> _logger;
 
     /// <summary>RUNNING 超过此时长视为僵死并自动关闭(防单飞永久堵死)。</summary>
@@ -33,6 +35,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
         WorkOrderRoutingSyncService routingSync,
         AidopActionRunLogWriter runLog,
         MdpRefreshDispatcher dispatcher,
+        PickBillNbrSyncService pickNbrSync,
         ILogger<ProductionSchedulingActionService> logger)
     {
         _db = db;
@@ -41,6 +44,7 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
         _routingSync = routingSync;
         _runLog = runLog;
         _dispatcher = dispatcher;
+        _pickNbrSync = pickNbrSync;
         _logger = logger;
     }
 
@@ -444,6 +448,20 @@ public class ProductionSchedulingActionService : IDynamicApiController, ITransie
             return;
         }
 
+        var woDomain = await _db.Ado.GetStringAsync(
+            """
+            SELECT IFNULL(`Domain`, '') FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+        if (await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, woDomain, account))
+        {
+            warnings.Add($"工单 {workOrd} 领料单已入队回写 165");
+            return;
+        }
+
         // 查找领料单主记录
         var nbrRows = await _db.Ado.SqlQueryAsync<NbrMasterRow>(
             """

+ 32 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/Production/WorkOrderSchedulingService.cs

@@ -1,4 +1,5 @@
 using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 
 namespace Admin.NET.Plugin.AiDOP.Production;
@@ -15,11 +16,16 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly PickBillNbrSyncService _pickNbrSync;
 
-    public WorkOrderSchedulingService(ISqlSugarClient db, UserManager userManager)
+    public WorkOrderSchedulingService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        PickBillNbrSyncService pickNbrSync)
     {
         _db = db;
         _userManager = userManager;
+        _pickNbrSync = pickNbrSync;
     }
 
     /// <summary>
@@ -1297,11 +1303,14 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
                 new SugarParameter("@Now", now),
                 new SugarParameter("@TenantId", tenantId));
 
-            // 非P状态需同步创建领料单NbrDetail
+            // 非P状态:Mes 入队回写 165;Local 仍写本库 NbrDetail
             if (!isP)
             {
-                await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
-                    input.QtyRequired, um, itemName, tenantId, account, now, isNew: true);
+                if (!await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, domain, account))
+                {
+                    await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
+                        input.QtyRequired, um, itemName, tenantId, account, now, isNew: true);
+                }
             }
 
             return new { message = "新增成功", recID = newRecId };
@@ -1329,11 +1338,14 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
                 new SugarParameter("@RecID", input.RecID),
                 new SugarParameter("@TenantId", tenantId));
 
-            // 非P状态需同步修改领料单NbrDetail
+            // 非P状态:Mes 入队回写 165;Local 仍写本库 NbrDetail
             if (!isP)
             {
-                await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
-                    input.QtyRequired, um, itemName, tenantId, account, now, isNew: false);
+                if (!await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, domain, account))
+                {
+                    await CreateOrUpdateNbrDetailForMaterial(workOrd, domain, input.ItemNum.Trim(),
+                        input.QtyRequired, um, itemName, tenantId, account, now, isNew: false);
+                }
             }
 
             return new { message = "保存成功" };
@@ -1376,18 +1388,21 @@ public class WorkOrderSchedulingService : IDynamicApiController, ITransient
             new SugarParameter("@RecID", input.RecID),
             new SugarParameter("@TenantId", tenantId));
 
-        // 非P状态需同步删除NbrDetail
+        // 非P状态:Mes 入队关 165 未领行;Local 仍删本库 NbrDetail
         if (woStatus != "P")
         {
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                DELETE FROM NbrDetail
-                WHERE WorkOrd = @WorkOrd AND ItemNum = @ItemNum AND Type = 'SM'
-                  AND tenant_id = @TenantId
-                """,
-                new SugarParameter("@WorkOrd", workOrd),
-                new SugarParameter("@ItemNum", itemNum),
-                new SugarParameter("@TenantId", tenantId));
+            if (!await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, domain, _userManager.Account ?? "system"))
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    """
+                    DELETE FROM NbrDetail
+                    WHERE WorkOrd = @WorkOrd AND ItemNum = @ItemNum AND Type = 'SM'
+                      AND tenant_id = @TenantId
+                    """,
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@ItemNum", itemNum),
+                    new SugarParameter("@TenantId", tenantId));
+            }
         }
 
         return new { message = "删除成功" };