Browse Source

fix(uat): land WP-A/B/C/D MES-WMS UAT defect remediation

Unify kitting stock freshness and net caliber, merge PSD actuals via hot watch, and fix outbox dead-letter alert. chore: bump version Web 2.4.286 / server 1.0.328

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 5 days ago
parent
commit
97fdc9590a
29 changed files with 4274 additions and 84 deletions
  1. 1 1
      Web/package.json
  2. 15 3
      Web/src/views/aidop/api/workOrderDispatch.ts
  3. 37 5
      Web/src/views/aidop/business/workOrderDispatchList.vue
  4. 504 0
      doc/plan/UAT留证/2026-08-09-S1起真人测试/03-WMS-步骤记录.md
  5. 135 0
      doc/plan/UAT留证/2026-08-09-S1起真人测试/README.md
  6. 136 0
      doc/plan/UAT缺陷整治任务书/README.md
  7. 211 0
      doc/plan/UAT缺陷整治任务书/WP-A-两项小修.md
  8. 591 0
      doc/plan/UAT缺陷整治任务书/WP-B-库存新鲜度打通.md
  9. 993 0
      doc/plan/UAT缺陷整治任务书/WP-C-齐套口径统一.md
  10. 625 0
      doc/plan/UAT缺陷整治任务书/WP-D-计划实绩合并.md
  11. 62 0
      doc/plan/旧DOP-MES-WMS/缺陷-D-W01-未知标签初始化.md
  12. 6 0
      server/Admin.NET.Application/Configuration/App.json
  13. 34 4
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  14. 35 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.sql
  15. 5 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.verify.sql
  16. 20 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.sql
  17. 4 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.verify.sql
  18. 86 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchAdminService.cs
  19. 361 1
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs
  20. 5 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxDeadLetterAlertJob.cs
  21. 32 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopStockOptions.cs
  22. 164 25
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs
  23. 84 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/Mes165StockReader.cs
  24. 39 17
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs
  25. 4 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs
  26. 7 9
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs
  27. 1 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs
  28. 33 12
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs
  29. 44 4
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderKittingCheckService.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-	"version": "2.4.280",
+	"version": "2.4.281",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 15 - 3
Web/src/views/aidop/api/workOrderDispatch.ts

@@ -45,12 +45,24 @@ export async function kitCheck(domain: string, userAccount: string): Promise<voi
 	});
 }
 
-/** 单工单齐套检查(替代旧 WorkOrdKittingCheckByNo) */
-export async function kittingCheckSingle(workOrd: string, domain: string, userAccount: string): Promise<void> {
-	await service.post('/api/WorkOrder/dispatch/work-order-kitting-check', null, {
+export interface KittingCheckSingleResult {
+	materialSituation?: string | null;
+	isKitted?: boolean;
+	shortageLineCount?: number;
+}
+
+/** 单工单齐套检查(替代旧 WorkOrdKittingCheckByNo);缺料属正常结论,由调用方按 isKitted 呈现 */
+export async function kittingCheckSingle(
+	workOrd: string,
+	domain: string,
+	userAccount: string
+): Promise<KittingCheckSingleResult> {
+	const res = await service.post('/api/WorkOrder/dispatch/work-order-kitting-check', null, {
 		params: { workord: workOrd, domain, userAccount },
 		timeout: 300000,
 	});
+	const body = res.data as (KittingCheckSingleResult & { result?: KittingCheckSingleResult }) | undefined;
+	return body?.result ?? body ?? {};
 }
 
 /** 生成物料需求 */

+ 37 - 5
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -21,6 +21,18 @@
 					style="width: 150px"
 				/>
 			</el-form-item>
+			<el-form-item label="状态">
+				<el-select
+					v-model="query.status"
+					multiple
+					collapse-tags
+					clearable
+					placeholder="不限状态"
+					style="width: 200px"
+				>
+					<el-option v-for="(label, code) in STATUS_MAP" :key="code" :label="`${label}(${code})`" :value="code" />
+				</el-select>
+			</el-form-item>
 			<el-form-item>
 				<el-button type="primary" @click="doSearch">查询</el-button>
 				<el-button @click="resetQuery">重置</el-button>
@@ -71,7 +83,11 @@
 					<el-button link type="warning" :loading="singleKittingWo === row.workOrd" @click="onSingleKitCheck(row)">
 						单工单齐套
 					</el-button>
-					<el-button link type="primary" @click="openRelease(row)">工单下达</el-button>
+					<el-tooltip :disabled="canRelease(row)" content="仅初始(p)状态工单可下达" placement="top">
+						<span>
+							<el-button link type="primary" :disabled="!canRelease(row)" @click="openRelease(row)">工单下达</el-button>
+						</span>
+					</el-tooltip>
 					<el-button link type="info" :loading="pickCreatingWo === row.workOrd" @click="onCreatePickBillRow(row)">
 						MES补偿
 					</el-button>
@@ -130,12 +146,16 @@ const userInfo = useUserInfo();
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '工单池下达');
 
+/** 默认显示下达池(p)与已下达(r),与批量齐套检查、MES 补偿建领料的覆盖范围保持一致 */
+const DEFAULT_STATUSES = ['p', 'r'];
+
 // ── 查询条件 ──
 const query = reactive({
 	workOrd: '',
 	itemNum: '',
 	descr: '',
 	ordDateFrom: '' as string,
+	status: [...DEFAULT_STATUSES] as string[],
 	page: 1,
 	pageSize: 20,
 });
@@ -151,10 +171,10 @@ function fmtDate(v?: string | null) {
 }
 
 const STATUS_MAP: Record<string, string> = {
-	w: '投产',
+	p: '初始',
 	r: '下达',
+	w: '投产',
 	c: '关闭',
-	p: '初始',
 };
 
 function statusLabel(s?: string | null) {
@@ -168,6 +188,11 @@ function statusTagType(s?: string | null): 'success' | 'warning' | 'danger' | 'i
 	return 'info';
 }
 
+/** 下达仅对初始(p)工单开放,与后端 AssertInitialWorkOrderExistsAsync 一致 */
+function canRelease(row: WorkOrderDispatchRow) {
+	return (row.status ?? '').trim().toLowerCase() === 'p';
+}
+
 function onSelectionChange(sel: WorkOrderDispatchRow[]) {
 	selectedRows.value = sel;
 }
@@ -198,6 +223,7 @@ function resetQuery() {
 	query.itemNum = '';
 	query.descr = '';
 	query.ordDateFrom = '';
+	query.status = [...DEFAULT_STATUSES];
 	query.page = 1;
 	loadList();
 }
@@ -210,6 +236,7 @@ async function loadList() {
 			itemNum: query.itemNum || undefined,
 			descr: query.descr || undefined,
 			ordDateFrom: query.ordDateFrom || undefined,
+			status: query.status.length ? query.status.join(',') : undefined,
 			page: query.page,
 			pageSize: query.pageSize,
 		});
@@ -247,11 +274,16 @@ async function onSingleKitCheck(row: WorkOrderDispatchRow) {
 	const account = userInfo.userInfos?.account ?? '';
 	singleKittingWo.value = row.workOrd;
 	try {
-		await kittingCheckSingle(row.workOrd, domain, account);
-		ElMessage.success(`工单 ${row.workOrd} 齐套检查已执行`);
+		const r = await kittingCheckSingle(row.workOrd, domain, account);
+		if (r.isKitted === false) {
+			ElMessage.warning(`工单 ${row.workOrd} 缺料(缺料行 ${r.shortageLineCount ?? 0})`);
+		} else {
+			ElMessage.success(`工单 ${row.workOrd} 齐套检查已执行`);
+		}
 		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '单工单齐套检查失败');
+		await loadList();
 	} finally {
 		singleKittingWo.value = null;
 	}

+ 504 - 0
doc/plan/UAT留证/2026-08-09-S1起真人测试/03-WMS-步骤记录.md

@@ -0,0 +1,504 @@
+# WMS 备料 / 发料 真人测试步骤记录
+
+| 项 | 内容 |
+|----|------|
+| 范围 | WMS APP(旧 DOP 同库 WMS)**生产备料 → 发料确认**;承接 S1/S2 产出的领料单 |
+| 环境 | SQL Server `dopdemorq` @ `123.60.180.165:1433`,Domain **`8010`** |
+| APP 账号 | **`qm08`**(此前误用 `G00309`,实际领料单 `User1=G00309` 但 APP 登录用 `qm08`) |
+| 样本 | 领料单 **`SM20260810001`** / 工单 **`M500000009`**(55 行明细) |
+| 版本 | server `1.0.327` / Web `2.4.285`(本轮未改产品代码) |
+| 时间 | 2026-08-11 00:00 – 00:55(进行中) |
+| 上游 | [`02-S2-步骤记录.md`](./02-S2-步骤记录.md)、[`旧DOP-MES-WMS主链路字段级变更.md`](../../旧DOP-MES-WMS/旧DOP-MES-WMS主链路字段级变更.md) |
+
+---
+
+## 1. 前置事实(先查库再操作)
+
+| 项 | 实测 |
+|----|------|
+| `MobileTask` / `MobileTaskHistory` | **0 行** → 本轮**不走**「我的任务」,直接在功能页手录领料单号 |
+| 领料单头 `NbrMaster` | `Status=''`、`IsConfirm=0`、`User1=G00309` |
+| 明细 `NbrDetail` | 55 行,`QtyRec=0`;54/55 行 `LocationFrom=1001` |
+| 目标行 | Line **2** = `1A03N54`(EEUE8000-5米),`QtyOrd=50`,`RecID=247877`,`OrdNbr/OrdLine` 已填(非「未推送 ERP」) |
+| 可用标签 | `MissedPrint` RecID **`126634`** / `1A03N54-20260809-AIDOP0001` / `Location=1001` / `LotSerial=2608090001` / `Qty=10` / `Status=N` / `Type=''` |
+| `InvControl` | `IsInvFixed=0`、`IsShelfAcc=1`、`IsLotControl=1` |
+| `CompanyCode` | **`TJRQ`**(触发 `SAPInv` 三道校验) |
+
+**扫码素材**:因演示环境无实物标签,按标签串生成图形码(同一字符串)
+[`screenshots/WMS-qr-1A03N54.png`](./screenshots/WMS-qr-1A03N54.png)(二维码,实际扫通的是这张)、[`screenshots/WMS-barcode-1A03N54.png`](./screenshots/WMS-barcode-1A03N54.png)(Code128)。
+
+---
+
+## 2. 步骤与结果
+
+### 2.1 生产备料(✅ 通过)
+
+| 步 | 操作 | 预期 | 实际 |
+|----|------|------|------|
+| 1 | `qm08` 登录 WMS APP → 「生产备料」 | 进入功能页 | ✅ |
+| 2 | 领料单录 `SM20260810001`;发料仓选 **`1001`** | 带出明细 | ✅ 55 行,见 [`WMS-01`](./screenshots/WMS-01-备料列表-SM20260810001.png) |
+| 3 | 扫二维码 `1A03N54-20260809-AIDOP0001` | 扫码成功并定位 | ✅ APP 自动定位到 `1A03N54` 行并高亮 |
+| 4 | 点右上角「提交」 | 备料落库 | ✅ 成功 |
+
+**扫码后台等价验证**(同参数直调,确认非客户端问题):
+
+```text
+EXEC pr_WMS_GetBarCodes @Domain='8010', @BarCode='1A03N54-20260809-AIDOP0001',
+     @PurOrd='SM20260810001', @Type='PrepareMaterial', @UserNo='qm08'
+→ Success=1「扫描成功」;RecID=126634, Qty=10, Location=1001, Status=N
+```
+
+**提交后库侧变化(真人操作产生)**:
+
+| 对象 | 变化 |
+|------|------|
+| `NbrMaster.Status` | `''` → **`A`**(备料中) |
+| `NbrDetail` RecID `247877` | `QtyFrom` 0 → **10**;`PUfld3` = `247877,126634,10`;`QtyRec` 仍 0 |
+| `MissedPrint` RecID `126634` | `PickQty` 0 → **10**;`Status` `N` → **`A`**;`OrdNbr=SM20260810001`;`WorkOrd=M500000009` |
+| `EmpWorkWmsHist` | 新增 1 行:`qm08` / `Type=SM` / `Qty=10` / `Location=1001` / `00:24:17` |
+
+### 2.2 界面观察:备料页右侧列需横向拖动才可见(🔶 易误读,非阻塞)
+
+接口 `pr_WMS_GetNbrDetails @Type='PrepareMaterial'` 返回的列定义是:
+
+`Line / ItemNum / Descr / ItemNumShelfInfo / QtyOrd(需求数) / QtyRec(已发数) / QtyFrom(已备数) / QtyScan(扫描数) / LocationFrom / LotSerial / Descr1 / UM / QtyOnHand / btnMatCard(物料卡)`
+
+**初判有误、已更正**:一度以为本版 APP 把「已备数/扫描数/物料卡」裁掉了([`WMS-03`](./screenshots/WMS-03-无扫描数列.png) 中最右列确实止于「已发数」)。实际是**表格支持横向拖动**——按住向左拖即可看到 `已备数 / 扫描数(可编辑输入框)/ 转出仓 / 批次号 / 型号 / 单位 / 库存数 / 物料卡「打印物料卡」`([`WMS-06`](./screenshots/WMS-06-横向拖动后右侧列.png))。
+
+叠加另一处易误读:「货架/批次推荐」列文案 `货架: 批次:2608090001 数量:10` 中的 **数量 10 是该批次可备提示**(`ItemNumShelfInfo`),**不是**已发/已备数([`WMS-02`](./screenshots/WMS-02-货架批次推荐提示.png))。
+
+**现场影响**:默认视口右边界正好落在「已发数」,且无横向滚动提示;扫码成功后在默认视口内看不到任何数量反馈(「已发数」按业务本就应为 0),极易误判为「扫了没反应」而重复扫码。本轮即因此往返数次。属旧 DOP APP 展示体验问题,登记为观察项。
+
+### 2.3 发料确认(❌ 未通过 — 库存前置数据不完整)
+
+| 步 | 操作 | 实际 |
+|----|------|------|
+| 1 | 进「发料确认」→ 录 `SM20260810001` | ✅ 只显示已备行:`1A03N54` 需求 50 / 已发 0 / 备料数 10([`WMS-04`](./screenshots/WMS-04-发料确认-备料数10.png)) |
+| 2 | 点「提交」 | ❌ **保存失败,以下物料_库位_批次没有库存(LocationDetail) `1A03N54_1001_2608090001`**([`WMS-05`](./screenshots/WMS-05-发料失败-无批次库存.png)) |
+
+APP 显示的「备料数」对应 `QtyFrom`。据 `pr_WMS_GetNbrDetails` 列定义反查,本页实际是 **`@Type='WorkOrdMaterialIssueH5'`** 分支(该分支 `QtyFrom` 标题正是「备料数」、删除「货架/批次推荐」列、并追加 `btnMatCard` 物料卡 + **`btnReturn` 退回**两列);`WorkOrdMaterialConfirm` 分支的标题则是「扫描数」。
+
+→ **重要推论**:发料确认页最右侧有**「退回」按钮**,可撤销单行已备料,1A03N54 的脏备料并非不可逆。
+
+### 2.4 打印物料卡(❌ 环境阻塞 — 无蓝牙打印机)
+
+在备料页横向拖到最右,对目标行点「打印物料卡」,APP 提示 **「没有可用蓝牙打印机,请连接蓝牙打印机」**([`WMS-10`](./screenshots/WMS-10-物料卡-无蓝牙打印机.png))。
+
+事后查库确认:**`MissedPrint` 无任何 `Type='Card'` 记录,也无新增行** → APP 是**先校验蓝牙打印机、失败即中断**,根本没有调用后台 `pr_WMS_CreateBarCodeCard`。本演示环境(无蓝牙打印机)下「打印物料卡」不可用,凡依赖「先建标识卡再入库」的路线全部作废。
+
+### 2.5 备料退回(✅ 通过 — 01:29:57)
+
+在「发料确认」页横向拖到最右,对 `1A03N54` 行点「退回」,APP 提示退回成功。查库确认**三处全部回滚到备料前状态**:
+
+| 对象 | 变化 |
+|------|------|
+| `NbrDetail` RecID `247877`(Line 2) | `QtyFrom` 10 → **0**;`PUfld3` `247877,126634,10` → **空**;`QtyRec` 仍 0;`Status` 仍 `''` |
+| `NbrMaster.Status` | `A`(备料中) → **`''`**(回到未备料) |
+| `MissedPrint` RecID `126634` | `PickQty` 10 → **0**;`Status` `A` → **`N`**;`OrdNbr` → **空** |
+
+→ 「退回」是**幂等可逆**的,脏备料已清除,Line 2 可重新备料。操作人 `qm08`。
+
+### 2.6 外部标签初始化(⚠️ 半通过 — 标签建成、库存未建,发现缺陷 D-W01)
+
+菜单位置:底部**「其他」页签** → 「外部标签初始化」([`WMS-12`](./screenshots/WMS-12-外部标签初始化-页面.png)、[`WMS-13`](./screenshots/WMS-13-外部标签初始化-条码已识别.png))。页面字段与 `pr_WMS_SaveUnknownLabelInit` 入参一一对应:
+
+| 页面字段 | 入参 | 本轮取值 |
+|----------|------|----------|
+| 仓架(扫码/下拉) | `@Shelf` | `1001:01-01-01` → 自动带出描述「合格库_地拖」,货架校验通过 |
+| 条码(扫码) | `@Details` 第 1 段 | `1A03N54-20260811-UAT01`(扫二维码,APP 从前缀自动识别物料并补出描述/规格) |
+| 物料编码 / 数量 | `@Details` 第 2、3 段 | `1A03N54` / `50` |
+
+APP 提示**提交成功**。查库结果:
+
+| 对象 | 结果 |
+|------|------|
+| `MissedPrint` | ✅ 新增 `1A03N54-20260811-UAT01`:`Qty=50`、`Location=1001`、`Shelf=01-01-01`、`LotSerial` 空、`Status=N`、`Remark='外部标签初始化产生标签'`、01:41:28 |
+| `MissedPrintTransHist` | ✅ 新增 1 行 |
+| **`LocationDetail`** | ❌ **仍 0 行** |
+| `InvMaster` (1A03N54@1001) | ❌ 仍 312,`UpdateTime` 停在 2025-11-30(未动) |
+| `QadTracking`(`Subject='外部标签初始化入库'`) | ❌ **0 行** |
+| `InvTransHist` | ❌ **0 行** |
+
+#### D-W01(旧 DOP WMS 缺陷):外部标签初始化无法建出「第一行」库存
+
+→ 已转缺陷报告 + 绕行办法,见 `doc/plan/旧DOP-MES-WMS/缺陷-D-W01-未知标签初始化.md`(Ai-DOP 侧不改 165)。
+
+`pr_WMS_SaveUnknownLabelInit` 组装记账入参(`@Inventorys`)的语句:
+
+```sql
+from (select Location,ItemNum,sum(Qty) as Qty from @tbNewRecID group by Location,ItemNum) t
+left join LocationDetail d with(nolock)
+  on d.Domain=@Domain and t.Location=d.Location and t.ItemNum=d.ItemNum
+left join (select a.Location,a.ItemNum,sum(IsNull(b.Qty,0)) as MissedQty ...) m
+  on d.Location=m.Location and d.ItemNum=m.ItemNum        -- ← 关联到 d,而非 t
+where IsNull(m.MissedQty,0) > IsNull(d.QtyOnHand,0)
+```
+
+标签总量子查询 `m` 的关联键取自 `d`(`LocationDetail`)。当该「物料+库位」**原本无库存行**时 `d.*` 全为 NULL → `m` 亦为 NULL → `MissedQty` 退化为 0 → `0 > 0` 不成立 → 该行被 `where` 过滤 → `@Inventorys` 为空 → 后续 `pr_SFM_InventoryTransactionProcessing2` 根本不被调用(`if exists(select top 1 * from @Inventorys)` 不成立)。
+
+**影响**:该功能只能对**已存在** `LocationDetail` 行的「物料+库位」做数量校平,**永远无法为零库存的物料+库位建立首行库存**——恰好与「期初/外部标签初始化」的功能语义相反。若 `m` 改为关联 `t`(`t.Location=m.Location and t.ItemNum=m.ItemNum`)即可修复。
+
+**验证旁证**:`pr_SFM_InventoryTransactionProcessing2` 的「没有库存(LocationDetail)」校验带 `having sum(-ChangeQty)>0` 条件,即**仅对净出库**生效;入库(`ChangeQty>0`)时会正常新建 `LocationDetail` 行。故只要入参非空,本例是能建出库存的 → 阻塞点确定在上述 `where` 过滤,而非记账过程。
+
+**遗留**:标签 `1A03N54-20260811-UAT01`(50 @ 1001,无库存支撑)已存在于库中。若后续测试者误扫此标签备料,仍会在发料时被拦截。建议用「标签冻结」封存或在环境重置时清理。
+
+### 2.7 外部标签初始化(✅ 通过 — 换 `A2C3115` @ `5007`,01:49:27)
+
+按 §4.3 算式操作:仓架 `5007:01-01-01`、条码 `A2C3115-20260811-UAT01`、物料 `A2C3115`、数量 **140**。APP 提示提交成功,查库结果**与预测逐项吻合**:
+
+| 对象 | 结果 |
+|------|------|
+| `MissedPrint` | ✅ 新增 `A2C3115-20260811-UAT01`:`Qty=140`、`Location=5007`、`Shelf=01-01-01`、`LotSerial` 空、`Status=N` |
+| `LocationDetail`(旧批次 `2608090002`) | 90 → **90(未动)** |
+| **`LocationDetail`(空批次)** | ✅ **新建 1 行 = 50**(= 140 − 90,`UpdateTime=01:49:27`) |
+| `InvMaster`(`A2C3115@5007`) | ✅ 已更新,`UpdateTime=01:49:27`,`QtyOnHand=83593` |
+| `InvTransHist` | ✅ 新增 1 行:`TransType=rct-unp`、`QtyChange=+50`、`Loc=5007`、`LotSerial=''`、`OrdNbr=260811014927263124`、`CreateUser=qm08` |
+
+→ 印证 D-W01 的判断:同一功能、同一操作,**只因目标「物料+库位」已有库存行**便正常记账;差异不在操作方式,而在那句 `left join ... on d.Location=m.Location`。
+
+### 2.8 再次备料(❌ 受阻 — SAP 镜像无空批次库存)
+
+「生产备料」→ `SM20260810001` → 发料仓选 **`5007`** → 扫 `A2C3115-20260811-UAT01`,APP 报:
+
+> **该物料(A2C3115)批次()在SAP没有可用的非限制库存**
+
+拦截点在 `pr_WMS_GetBarCodes`(`CompanyCode='TJRQ'` 专属分支,`left(@Location,3)<>'VMI'` 时生效):
+
+```sql
+if IsNull((select top 1 LABST from SAPInv
+             where Domain=@Domain and MATNR=@ItemNum and CHARG=@LotSerial and LGORT=@Location),0)<=0
+```
+
+`@ItemNum` / `@LotSerial` / `@Location` **均取自标签**(`MissedPrint`,见该过程 457 行)。我们的新标签批次为空、库位 5007,而 `SAPInv` 现有两行都带具体批次:
+
+| MATNR | CHARG | LGORT | LABST | 来源 |
+|-------|-------|-------|-------|------|
+| `1A03N54` | `2608090001` | `1001` | 100 | `AIDOP_GU` 08-09 造 |
+| `A2C3115` | `2608090002` | `5007` | 100 | `AIDOP_GU` 08-09 造 |
+
+→ 空批次查不到行,`LABST` 视作 0,扫码被拒。该校验**逻辑正确**(防止 WMS 备料超出 SAP 非限制库存),缺的是演示环境的 SAP 镜像数据。
+
+---
+
+## 3A. 死锁分析:为什么"纯 APP 操作"在本环境走不通
+
+三个约束互相咬死:
+
+| 约束 | 来源 | 后果 |
+|------|------|------|
+| ① 备料扫码要求 `SAPInv(物料, 批次, 库位).LABST>0` | `pr_WMS_GetBarCodes`(TJRQ 分支) | 标签批次必须命中 SAP 镜像已有行,即**必须是非空批次** `2608090001` / `2608090002` |
+| ② APP 能创建的标签,批次恒为空 | `pr_WMS_SaveUnknownLabelInit` 不接收批次入参(`LotSerial` 不赋值) | 与 ① 直接冲突 |
+| ③ 发料要求 `LocationDetail(物料, 库位, 标签批次)` 有量 | `pr_SFM_InventoryTransactionProcessing2` | 老标签(批次对得上 ①)恰好没有该批次库存行,而 D-W01 又让初始化建不出首行 |
+
+**其余可创建非空批次标签的入口全部不可用**:
+
+| 入口 | 状态 |
+|------|------|
+| 打印物料卡 `pr_WMS_CreateBarCodeCard` | ❌ 无蓝牙打印机(O-W04),且该物料已有带库位标签时被拒 |
+| 采购收货 | ❌ 全库无采购单 |
+| 拆分标签 | ❌ 需一张可用源标签(`A2C3115` 老标签 `Status='C'` 已消耗、`Location=''`) |
+| **库存初始化 / 产生初始标签** `pr_WMS_SaveInitBarCodeByInv` | ⚠️ **入参支持手工指定批次**(`LotSerial`),是本环境唯一能造出批次 `2608090002` 标签的功能——但它是 **PC 端菜单**(`rf_menu` 中 `type=0`,「库存初始化」`sort=70`),APP 无此项 |
+| 旧 DOP PC 前端 / Business API | ❌ 本轮实测 `8020` / `8022` / `8087` 均返回 **503**,无法通过界面或接口调用上述功能 |
+| VMI 库位豁免(`left(@Location,3)='VMI'` 可跳过 SAP 校验) | ❌ 3 个 VMI 库位(`VMI00094` / `VMI01` / `VMI5006`)均无本单物料的 `LocationDetail` 行(撞 D-W01),且 `Storer` 都不含 `qm08`(备料会报库位无权限) |
+
+**结论**:在"不补任何数据"的前提下,本演示环境**无法**用 APP 走通发料。需在下列三个口子中选一个开:SAP 镜像行、`LocationDetail` 行、或修 D-W01 + 恢复 PC 端/API 可达。
+
+### 3A.1 决策与执行:补 1 行 SAP 库存镜像(02:02:18)
+
+**用户决策**:开 SAP 镜像这个口子。
+
+**理由**:`SAPInv` 是**上游 SAP 库存镜像表**,真实部署由 SAP 接口定时同步(`SyncERPDataMenuSet`),并非 WMS 业务表;本演示环境无 SAP 接入,该表数据本就缺失(现有两行也是 08-09 由 `AIDOP_GU` 补入)。而 WMS 侧实物库存(`5007` 空批次 50)已由 §2.7 的 APP 真实操作产生,发料扣减的是这笔真库存 → **业务操作结果全部来自真人操作,补的仅是缺失的上游系统数据**。
+
+**执行内容**(幂等,`if not exists` 保护;经 `dopsa` 直连执行,MCP 连接为只读):
+
+```sql
+insert SAPInv(Domain,ZERDAT,WERKS,MATNR,LGORT,LGOBE,MAKTX,LABST,INSME,SPEME,MEINS,CHARG,
+              Process,CreateTime,CreateUser,UpdateTime,UpdateUser)
+values('8010','2026-08-11','8010','A2C3115','5007','线边库','x53一联标签单层底纸空白',
+       50,0,0,'PC','',0,getdate(),'AIDOPUAT',getdate(),'AIDOPUAT');
+```
+
+| 字段 | 取值 | 说明 |
+|------|------|------|
+| `CHARG` | `''`(空批次) | 与 §2.7 生成的标签、库存行批次口径一致 |
+| `LGORT` | `5007` | 与标签库位一致(校验按物料+批次+库位三键匹配) |
+| `LABST` | **50** | = APP 实际建出的空批次库存量,账实一致 |
+| `INSME` | 0 | 避免命中「SAP 是质检状态库存」那道校验 |
+| `CreateUser` | `AIDOPUAT` | 便于回溯清理。注意该列 `nvarchar(16)` 实为 **8 字符**,`AIDOP_UAT`(9 字符)会因截断报错 |
+
+执行后 `SAPInv(A2C3115)` 两行并存:空批次 50(本轮补)+ `2608090002` 100(08-09 补),原行未动。
+
+---
+
+## 3. 失败根因(❗ 不是缺陷,是演示数据不完整)
+
+| 表 | `1A03N54` @ `1001` / 批次 `2608090001` |
+|----|----------------------------------------|
+| `MissedPrint`(标签) | 有,`Qty=10`、已备 `PickQty=10` |
+| `InvMaster`(库位总账,不分批次) | 有,`QtyOnHand=312` |
+| `SAPInv` | 有,`LABST=100`(`TJRQ` 三道校验可过) |
+| **`LocationDetail`(物料+库位+批次实物库存)** | **0 行** ← 发料校验在此拦截 |
+
+结论:此前为跑通备料只造了**标签**(`MissedPrint`),没造对应**批次库存**(`LocationDetail`),属半套数据。发料的库存校验**正确拦截**,165 侧无缺陷。
+
+### 3.1 环境盘点(决定补库存路线的依据)
+
+| 项 | 实测 |
+|----|------|
+| `PurOrdMaster`(采购订单) | **全库 0 行** |
+| `srm_polist_ds`(交货计划) | 0 行 |
+| `PurOrdRctMaster`(收货记录) | 0 行 |
+| `MissedPrint`(Domain 8010 标签总数) | **仅 5 条** |
+| `LocationDetail`(Domain 8010) | 56,374 行,但 55 行领料明细中**只有 1 个物料**有库存:`A2C3115` @ `5007` / 批次 `2608090002` / **90**(`CreateUser=AIDOP`,2026-08-09 造) |
+| 旧 DOP 前端 | `8020/8022/8024` **不可达**;`8087` Business API 在跑;`8080` 是无关的「芋道管理系统」 |
+| Ai-DOP → 165 推送能力 | 无 `PurOrd` 相关 action(仅 S1/S2/S4/S5/S6/S7 若干) |
+
+→ **APP 收货链(收货打标 → 上架)在本环境不可走**:一张采购单都没有,且要走就得从零伪造供应商+采购订单+交货计划,造数规模远大于其它方案。
+
+---
+
+## 4. 补库存路线(APP 内真实操作,逐条排除后定案)
+
+**前提约束**:用户明确要求**不后台 INSERT** `LocationDetail`,必须走 APP 正式功能。据此把 165 上所有可能建库存的入口逐个读存储过程验证:
+
+| 候选路线 | 过程 | 结论 |
+|----------|------|------|
+| 采购收货 → 上架 | `pr_WMS_SavePurOrdRct` 等 | ❌ 全库无采购单/交货计划(见 §3.1),造数规模最大 |
+| 计划外入库 | `pr_WMS_DirectInBound` | ❌ 要求标签**库位为空且数量为 0**;1A03N54 现有标签 `Location=1001`、`Qty=10`,命中「标签已有库存数量,不能使用」「标签库位必须为空」两道校验 |
+| 先建标识卡再入库 | `pr_WMS_CreateBarCodeCard` | ❌ 双重堵死:① 该过程开头即拒绝「已是箱标签管理」的物料(1A03N54 已有带库位标签);② APP 端无蓝牙打印机,压根到不了后台(见 §2.4) |
+| 扫描盘点 | `pr_WMS_SaveCycleCounting` | ❌ 只 update `MissedPrint` 的 `Qty/ProdLine/Op`(在制品盘点),**完全不碰** `LocationDetail`/不记账 |
+| 上架移库 | `pr_WMS_SaveInvMove` / `BPM_SaveInvUpShelf` | ❌ 需已有源库存 |
+| **外部标签初始化** | `pr_WMS_SaveUnknownLabelInit` | ✅ **唯一可行**:扫外部条码由后台建标签,**不需要打印机**,且内部记账真实写库存 |
+
+### 4.1 选定路线:外部标签初始化 + 发料确认页「退回」
+
+| 项 | 实测 |
+|----|------|
+| 过程 | `pr_WMS_SaveUnknownLabelInit`;入参 `@Details='BarCode,ItemNum,Qty'`、`@Shelf`、`@UserNo` |
+| 建标签 | 直接 insert `MissedPrint`(`Status='N'`、`Type=''`、`LotSerial` 不赋值即 NULL、`Remark='外部标签初始化产生标签'`) |
+| 记账 | `EXEC pr_SFM_InventoryTransactionProcessing2`,事务类型 **`rct-unp`** → 真实写 `InvTransHist` / `LocationDetail` / `InvMaster` |
+| 记账口径(注意) | `ChangeQty` = 该「物料+库位」下**所有非 C 状态标签数量之和** − 现有 `LocationDetail.QtyOnHand`。1A03N54 @ 1001 现存旧标签 10,若新初始化 50,则库存会被补到 **60**(含那 10 张来源不明的旧标签),属该过程「以标签总量校平库存」的既定语义,非缺陷 |
+| 批次 | 记账时固定 `LotSerial=''`;新标签 `LotSerial` 亦为空,领料明细 line 2 的 `LotSerial` 也为空 → 三者一致,发料校验可过 |
+| 前置校验 | `LocationShelfMaster` 必须存在该「库位+货架」;条码不得与 `MissedPrint` 已有重复 |
+| 货架码格式 | `ShelfSplitCode` **未配置** → 默认 `:`,即 `库位:货架` |
+| 选用货架 | `1001` / `01-01-01`(描述「地拖」,`LocationShelfMaster` RecID 1361,已确认存在) |
+| 库位权限 | `IsShelfAcc=1`;`LocationMaster('1001').Storer` 含 **`qm08`** → 权限可过 |
+
+**为什么 1A03N54 仍是可行目标**(一度判为死路,已更正):脏备料 10(旧标签批次 `2608090001`)与新标签同挂 line 2 的 `PUfld3`,发料时会一并校验,所以**必须先撤销**;而发料确认页恰好带 `btnReturn`「退回」列(见 §2.3),可单行撤销。撤销后 line 2 干净,可用新标签重走备料 → 发料。
+
+**扫码素材**(`_tmp_uat_codes/`,临时目录):
+
+| 用途 | 内容 |
+|------|------|
+| 货架码 | `1001:01-01-01` |
+| 新物料标签 | `1A03N54-20260811-UAT01`(已确认 165 上不存在) |
+
+### 4.2 菜单可达性(当前卡点)
+
+| 项 | 实测 |
+|----|------|
+| 菜单表 | `rf_menu`,仓库父节点 `414420300472389` 下共 **40** 项 |
+| 目标菜单名 | **「外部标签初始化」**(`id=425732699447365`,`sort=295`,位于「销售退货」之后)——**不叫**「未知标签初始化」 |
+| 相关项 | 另有独立的「无源入库」(`sort=165`,紧随「计划外出库」);APP 上可见的「计划外入库」应对应 `PlanInBoundH5`(按计划外入库**单**作业),与 `pr_WMS_DirectInBound` 不是同一入口 |
+| APP 实际位置 | ✅ **已找到:在底部「其他」页签**,而非「仓库」页签。「其他」含 12 项:委外发料/退料/收货/退货、检验确认、报废确认、**无源入库**、无源出库、标签冻结/解冻、**外部标签初始化**、IQC留样([`WMS-11`](./screenshots/WMS-11-其他页签-外部标签初始化.png)) |
+| 权限表 | 与可见性无关:`rf_menu_user` 按 `organizes`(`u_<组织id>`)授权,而 `qm08`(`rf_user.id=460777112723525`)所属组织 `460498793439301`「智能制造」整条组织链 0 条授权行,功能却可用 → APP 是按页签分组展示全部 40 项,非权限过滤 |
+
+### 4.3 绕开 D-W01:改用 Line 1 / `A2C3115` @ `5007`(借已有库存行"起跳")
+
+D-W01 决定了一个硬约束:**目标「物料+库位」必须已有至少一行 `LocationDetail`**,外部标签初始化才会记账。据此重扫 55 行明细,符合该条件的**只有 Line 1**:
+
+| 项 | 实测 |
+|----|------|
+| Line 1 | `A2C3115` / `QtyOrd=250` / `LocationFrom=5007` / `LocationTo` 空(不触发「标签已在此库位」) |
+| 现有库存 | `LocationDetail(A2C3115, 5007, 批次 2608090002) = 90`(唯一 1 行,`CreateUser=AIDOP`) |
+| 现有标签 | `A2C3115-20260809-AIDOP0001`,`Location=''`、`Status='C'`(已消耗)→ **不计入** `MissedQty`,且无法用于备料 |
+| 库位权限 | `LocationMaster('5007')`「线边库」`Storer` 含 **`qm08`** ✅ |
+| 货架 | `5007:01-01-01`「1层第1格」存在 ✅ |
+
+**记账算式**(该过程语义 = 把库存校平到"该物料该库位标签总量"):
+
+- `d`(`LocationDetail`)关联时**不带批次**,命中批次 `2608090002` 那行 → `QtyOnHand=90`
+- 新标签数量填 **140** → `MissedQty=140` → `ChangeQty = 140 − 90 = 50 > 0` ✅ 记账成立
+- 记账固定 `LotSerial=''` → `pr_SFM_InventoryTransactionProcessing2` 按「物料+库位+批次」找不到行,但因是入库(`ChangeQty>0`)→ **新建** `LocationDetail(A2C3115, 5007, '')= 50`
+- 结果:该库位该物料 = 90(旧批次)+ 50(空批次)= 140 = 标签总量,**账实自洽**
+
+**关键点**:新标签批次为空、新库存行批次为空、Line 1 的 `NbrDetail.LotSerial` 也为空 → 三者一致,发料的批次库存校验可过。
+
+**扫码素材**:`5007:01-01-01`、`A2C3115-20260811-UAT01`(由 [`_tmp_gen_uat_codes.py`](../../../../_tmp_gen_uat_codes.py) 生成,已确认该条码在 `MissedPrint` 不存在)
+
+**待执行步骤**:
+
+1. 「其他」→「外部标签初始化」:仓架 `5007:01-01-01` → 条码 `A2C3115-20260811-UAT01` / 物料 `A2C3115` / 数量 **140** → 提交;预期新建 `LocationDetail(A2C3115,5007,'')=50`、`InvMaster(5007)` +50、`InvTransHist` 新增 `rct-unp`
+2. 「生产备料」录 `SM20260810001` → 发料仓选 **`5007`** → 扫新条码 → 扫描数 **50** → 提交
+3. 「发料确认」→ Line 1 → 提交 → 预期 `NbrDetail.QtyRec` ↑50、`MissedPrint.QtyIssued` ↑、`WorkOrdDetail.QtyPosted` ↑、`LocationDetail(...,'')` 50→0
+4. 核 Ai-DOP 侧齐套 / `mes_morder.MaterialSituation` 回写
+
+**1A03N54(Line 2)本轮结论**:受 D-W01 阻塞,在"不后台造数"前提下**无法**在演示环境补出批次库存,本轮不再作为发料样本;已退回备料、保持干净。
+
+### 2.9 生产备料 · Line 1(✅ 通过 — 02:07:04)
+
+「生产备料」→ `SM20260810001` → 发料仓 **`5007`** → 扫 `A2C3115-20260811-UAT01` → 扫码校验通过(补入 SAP 镜像行后不再报错)。
+
+**界面观察**:扫码后「扫描数」**自动带出 140**(= 整标签数量),需手工改为 **50**。原因:空批次可用库存只有 50(另 90 挂在批次 `2608090002`,发料按标签批次严格匹配取不到)。备料本身**不校验库存**,若按 140 提交这步照样成功,直到发料才会撞 `pr_SFM_InventoryTransactionProcessing2` 的「库存不足」校验并需回退重备 → 登记为观察项 O-W06。
+
+| 对象 | 变化 |
+|------|------|
+| `NbrDetail` RecID `247876`(Line 1) | `QtyFrom` 0 → **50**;`PUfld3` = `247876,126640,50`;`LocationFrom=5007`;`QtyRec` 仍 0 |
+| `NbrMaster.Status` | `''` → **`A`** |
+| `MissedPrint` RecID `126640` | `Qty=140`(未变);`PickQty` 0 → **50**;`Status` `N` → **`A`**;`OrdNbr=SM20260810001`;`WorkOrd=M500000009` |
+| `EmpWorkWmsHist` | 新增 1 行:`qm08` / `A2C3115` / `Qty=50` / `Location=5007` / 批次空 / 02:07:04 |
+
+### 2.10 发料确认 · Line 1(✅ 通过 — 02:08:53)
+
+「发料确认」→ `SM20260810001` → 提交,APP 提示成功。**WMS 主链路(备料 → 发料)至此完整跑通**。
+
+| 对象 | 变化 |
+|------|------|
+| `NbrDetail` RecID `247876`(Line 1) | `QtyRec` 0 → **50**;`QtyFrom` 50 → 0;`PUfld3` 清空;`Status` 仍 `''`(需求 250 未满,未置 `C`) |
+| `NbrMaster.Status` | `A` → `''`(本单已无在备料量) |
+| `MissedPrint` RecID `126640` | `Qty` 140 → **90**(扣 50);`PickQty` 50 → 0;`Status` `A` → `N`;仍在 5007 |
+| `LocationDetail`(空批次) | 50 → **0** |
+| `LocationDetail`(`2608090002`) | 90 → 90(未动,证明批次隔离正确) |
+| `InvTransHist` | 新增:`TransType=iss-wo`、`QtyChange=**-50**`、`Loc=5007`、`LotSerial=''`、`OrdNbr=SM20260810001` |
+| `WorkOrdDetail`(`M500000009` / `A2C3115`) | `QtyPosted` 0 → **50**(`QtyRequired=75`) |
+| `QadTracking` | 新增:`Subject='物料发料确认'`、`TransType=iss-wo`、`Order=SM20260810001`、`Department='生产'` → 已进 ERP/SAP 推送队列 |
+
+与 [`旧DOP-MES-WMS主链路字段级变更.md`](../../旧DOP-MES-WMS/旧DOP-MES-WMS主链路字段级变更.md) 中 `pr_WMS_SaveWorkOrdMaterialIssue` 的预期字段变更**逐项一致**。
+
+### 2.11 Ai-DOP 侧齐套状态(⚠️ 不随发料自动刷新 — 设计如此,非缺陷)
+
+发料成功后查 Ai-DOP(`aidopdev.mes_morder`):
+
+| 字段 | 值 |
+|------|-----|
+| `morder_no` | `M500000009` |
+| `morder_state` | 下达 |
+| **`MaterialSituation`** | **缺料**(未变) |
+| `update_time` | `2026-08-10 15:22:16`(**发料之前**) |
+
+查代码确认这是**动作驱动**而非事件驱动:`MaterialSituation` 仅由 [`WorkOrderKittingCheckService`](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderKittingCheckService.cs) 重算并写入,入口是 `WorkOrderDispatchService` 上的两个动作——「单工单齐套检查」(`SingleKittingCheck?workord=&domain=`,下达后或手动触发)与「批量齐套检查」(`BatchKittingCheck`)。**WMS 发料不会回调 Ai-DOP 触发重算**。
+
+→ 待验证项:手动触发单工单齐套检查后,`MaterialSituation` 是否按新库存/已发料重算。注意本单发料仅覆盖 `A2C3115` 需求 75 中的 50,且 `1A03N54`(需求 15)仍无批次库存,**预期仍为「缺料」**,届时需据缺料明细行(`b_bom_child_examine`)核对口径,而非仅看这一个字段。
+
+### 2.12 前置阻塞:已下达工单在界面上无齐套入口(❌ 缺陷 D-A01,本轮已修)
+
+准备触发单工单齐套时发现:**工单下达页(FUNC-S1-006)查不到 `M500000009`**。
+
+根因:`WorkOrderDispatchService.GetDispatchList` 把状态条件写死为 `a.Status = 'p'`,而 `M500000009` 已下达(`Status='r'`)。行级「单工单齐套」按钮只挂在列表行上,因此**已下达工单无法做单工单齐套**,唯一可覆盖 `r` 的入口是工具栏整租户批量检查。
+
+判定为缺陷而非设计意图,页面自身代码即自相矛盾:
+
+| 证据 | 说明 |
+|------|------|
+| 状态列是死代码 | 前端 `STATUS_MAP` 定义 `p/r/w/c` 四态、`statusTagType` 为 `r`/`w`/`c` 分别配色,但 `p` 过滤下永远只渲染灰色「初始」 |
+| 领料单号列注定为空 | 列表 SQL 专门 `LEFT JOIN NbrMaster` 取 `PrevNbr`/`Nbr`,而领料单只在下达后产生 |
+| 「MES补偿建领料」不可达 | 该按钮的目标场景正是「本库已置 `r`、MES 领料单未建成」(代码中 `requireStatusP: false` 分支),而这类行在 `p` 过滤下不可见 |
+| 批量与可见范围不一致 | 批量齐套后端跑 `Status IN ('p','r')` 共 18 张,界面只看得到 7 张 `p` |
+
+修复(前后端各 2 文件,改动仅限列表可见范围,未动下达/建领料/齐套算法):
+
+| 文件 | 改动 |
+|------|------|
+| `WorkOrder/Dto/WorkOrderDispatchDto.cs` | `WorkOrderDispatchListInput` 增加 `Status`(逗号分隔多值) |
+| `WorkOrder/WorkOrderDispatchService.cs` | 去掉写死的 `a.Status='p'`,按入参拼 `IN (...)`;入参为空则不限状态 |
+| `Web/src/views/aidop/business/workOrderDispatchList.vue` | 查询区加「状态」多选下拉(默认 `p`+`r`,与批量齐套覆盖范围一致);`STATUS_MAP` 调整为 p/r/w/c 顺序;非 `p` 行禁用「工单下达」并提示原因 |
+
+验证:重启后端(1.0.327 构建产物)+ 前端热更新后,`M500000009` 可查到、状态标签显示绿色「下达」、「工单下达」按钮置灰、行级「单工单齐套」可点。
+
+### 2.13 单工单齐套检查(✅ 执行成功 — 02:45:06,但返回 HTTP 400)
+
+真人点击 `M500000009` 行的「单工单齐套」,页面弹出两条**红色**提示:
+
+1. `工单 M500000009 齐套检查未通过(缺料行 3)`
+2. `Request failed with status code 400`
+
+**检查实际是成功执行的**,三处均已落库:
+
+| 校验点 | 结果 |
+|--------|------|
+| 新检查记录 | `b_examine_result` 新增 `Id=836494557945925`(`create_time` 02:45:06、`create_by_name=AIDOPDemo`、`tenant_id=797403760988229`) |
+| 旧记录失效 | 原活动记录 `836444707586117` 被置 `IsDeleted=1`,其明细 `is_use=0` |
+| 齐套状态回写 | `mes_morder.MaterialSituation` = 缺料,`update_time` 由 `2026-08-10 15:22:16` 刷新为 **`18:45:06`(UTC) / 02:45:06(+08)** |
+
+红色报错源于设计:`SingleKittingCheck` 在 `!result.IsKitted` 时主动 `throw Oops.Oh(...)` → HTTP 400。「缺料」是正常业务结论而非执行失败,却与真实故障在 UI 上完全同形;且前端 `catch` 分支不会执行 `loadList()`,缺料时列表不刷新 → **观察项 O-A01**。
+
+本次 3 个缺料行:
+
+| 物料 | 名称 | 需求 | 可用 `sqty` | 缺口 |
+|------|------|------|-------------|------|
+| `81HC0498` | 座-8000-新 | 15 | 13 | 2 |
+| `91A0DC8` | 输出48V电源 | 15 | 8 | 7 |
+| `91DC0198` | 主机后铭牌面贴-AP9-中文 | 15 | 7 | 8 |
+
+`A2C3115` 与 `1A03N54` 本次**均不缺料**(见 §2.14 说明为何与上一轮相反)。
+
+### 2.14 齐套库存口径不含 WMS 实时库存(❌ 缺陷 D-A03)
+
+`sqty` 的口径([`MaterialRequirementCalculator.QueryStockQtyAsync`](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs)):对 **aidopdev 库 `InvMaster`** 按 `ItemNum + tenant_id` 汇总 `AvailStatusQty`(回退 `QtyOnHand`),**不区分工单 `LocationFrom`**,跨全部库位相加。
+
+而今晚 WMS 的真实过账写在 **MES 库(SQL Server `dopdemorq`)的 `InvMaster` / `LocationDetail`**。两份副本已不同步:
+
+| 物料 / 库位 | Ai-DOP `aidopdev.InvMaster` | MES `dopdemorq.InvMaster` |
+|-------------|------------------------------|----------------------------|
+| `A2C3115` @5007 | `83553`,`UpdateTime` **2025-12-31** | `83543`,`UpdateTime` **2026-08-11 02:08:53** |
+| `A2C3115` @1000 | `AvailStatusQty=5000` | `AvailStatusQty=0` |
+| `1A03N54` 合计 | `313`(0+312+0+1,2025 年数据) | 同 313 |
+
+今晚两条真实流水(`InvTransHist`)在 Ai-DOP 侧**毫无体现**:
+
+| 时间 | `TransType` | 物料/库位/批次 | `QtyChange` | 来源 |
+|------|-------------|----------------|-------------|------|
+| 01:49:27 | `rct-unp` | `A2C3115` / 5007 / 空 | **+50** | 外部标签初始化 |
+| 02:08:53 | `iss-wo` | `A2C3115` / 5007 / 空 | **−50** | 发料给 `M500000009` |
+
+代码中存在同步定义 `S3_INVENTORY`(`InvMaster` → `mdp_stg_work_order_material`,见 [`S3MdpSyncTransformService.cs`](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs)),但该链路未在运行,故 Ai-DOP 齐套结论不反映 WMS 实时库存。
+
+附:WMS 侧账实四处自洽(+50 / −50 / 空批次归零 / `NbrDetail.QtyRec=50`),本缺陷仅存在于 Ai-DOP 的库存副本。
+
+### 2.15 两个齐套入口口径不一致且互相覆盖(❌ 缺陷 D-A02)
+
+同一工单 `M500000009` 两次检查结果差异悬殊,**并非数据变化,而是口径不同**:
+
+| 入口 | 时间 | `bangId` | 是否扣库存占用 | 缺料行数 | `1A03N54` `sqty` | `A2C3115` `sqty` |
+|------|------|----------|----------------|----------|------------------|------------------|
+| 排产生成 | 08-10 23:22 | `836444340486213` | **是** | **49** | 0(缺 15) | 78405.7778 |
+| 单工单齐套 | 08-11 02:45 | `0` | **否** | **3** | 313(不缺) | 88553 |
+
+机制:`CheckShortageAndInTransit` 仅在 `bangId > 0` 时读 `ic_item_stockoccupy` 扣减同批次已被高优先级工单占用的库存。实测该批次占用量 `1A03N54` 313(占满)、`A2C3115` 11638.8888(M500000009 处理时点为 10147.2222,与 `88553 − 78405.7778` 吻合)。
+
+风险:单工单齐套按毛库存出乐观结论,且执行时会把排产那条净口径记录置 `IsDeleted=1`、明细 `is_use=0`——**一次点击即覆盖并丢失排产结论**,操作员无从分辨当前「齐套/缺料」出自哪种口径。
+
+### 2.16 附带发现:Outbox 死信告警任务持续报错(❌ 缺陷 D-A04,与本轮测试无关)
+
+后端 `20260811_Error.log` 中每 5 分钟一条:定时任务【每 5 分钟扫描 Outbox 告警】失败,`SqlSugar.SqlSugarException: Field "_lastSeenDeadMaxId" can't be private`,位置 [`MdpOutboxDeadLetterAlertJob.cs:50`](../../../../server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxDeadLetterAlertJob.cs)。SqlSugar 表达式解析不支持私有字段,该任务自启动起从未成功执行 → Outbox 死信告警实际未生效。既存缺陷,本轮未修。
+
+---
+
+## 5. 本轮结论与遗留
+
+| 项 | 结果 |
+|----|------|
+| WMS 备料(扫码 → 提交 → 落库) | ✅ 通过(`1A03N54` 01:24 一轮 + `A2C3115` 02:07 一轮),字段变化与字段级文档一致 |
+| WMS 备料退回 | ✅ 通过(01:29),三处字段完整回滚 |
+| WMS 外部标签初始化 | ⚠️ 半通过:`A2C3115@5007` ✅ 记账成功;`1A03N54@1001` ❌ 只建标签不建库存 → **缺陷 D-W01** |
+| WMS 发料确认 | ✅ **通过**(02:08),库存扣减 / 工单回写 / ERP 推送队列均正确 |
+| WMS 主链路(备料 → 发料) | ✅ **完整跑通** |
+| Ai-DOP 侧齐套状态回写 | ⚠️ 不自动刷新(动作驱动),见 §2.11 |
+| Ai-DOP 工单下达列表可见范围 | ❌ 已下达工单查不到 → **缺陷 D-A01**,本轮已修并验证(§2.12) |
+| Ai-DOP 单工单齐套检查(真人触发) | ✅ 执行成功、新记录与 `MaterialSituation` 均已回写(§2.13);返回 400 见 O-A01 |
+| Ai-DOP 齐套库存口径 | ❌ 读 aidopdev 静态 `InvMaster`,不含 WMS 实时库存 → **缺陷 D-A03**(§2.14) |
+| 齐套两入口口径一致性 | ❌ 排产(净)与单工单(毛)结论相反且互相覆盖 → **缺陷 D-A02**(§2.15) |
+| Outbox 死信告警任务 | ❌ 持续报错、从未生效 → **缺陷 D-A04**(§2.16,既存、与本轮无关) |
+| MES 冻结期保护(依赖已发料) | ⬜ 待做 — 前置条件现已满足(`QtyPosted=50`) |
+| MES `ActualTime`(先离岗后结束计划) | ⬜ 待做 |
+
+| 观察项 | 说明 |
+|--------|------|
+| O-W01 | 备料页默认视口右边界止于「已发数」,「扫描数/已备数/物料卡」需横向拖动才可见且无滚动提示;叠加推荐列数量易被读作已发数 → 扫码成功无可见反馈,操作员易重复扫码。旧 DOP APP 展示体验问题 |
+| O-W04 | 「打印物料卡」强依赖蓝牙打印机,无打印机时前端直接中断、不落库;演示/远程 UAT 环境下该功能及其下游路线全部不可用 |
+| O-W06 | 备料扫码后「扫描数」默认带整标签数量,且备料环节不校验库存;操作员若不改数量,要到发料才被「库存不足」拦下并需退回重备。建议备料时即按「标签批次可用库存」给出上限提示 |
+| O-W05 | 仓库类功能被拆到「仓库」与「其他」两个页签,且「其他」页签名毫无业务含义:`无源入库`、`外部标签初始化`、`检验确认`、`委外*` 等 12 项关键仓储功能藏在「其他」里。首次使用者按业务语义在「仓库」页签遍历会找不到,本轮即因此绕了一大圈 |
+| O-W02 | 演示库 WMS 基础数据严重不完整:采购/收货/标签近乎全空,库存与标签不成对。后续 WMS 场景需先补一份**成对**的期初数据(标签 + 批次库存),否则每个场景都会卡在校验 |
+| O-W03 | 领料单未推 `MobileTask`,「我的任务」无待发料任务;与 WP7 §4「建单后推送 `PrepareMaterial` 任务」的设计不符,需确认 Ai-DOP 侧是否已实现该推送 |
+| O-A01 | 齐套检查结论为「缺料」时后端抛 `Oops.Oh` 返回 HTTP 400,前端弹红色错误提示,与真实执行失败在 UI 上完全同形;且前端进入 `catch` 分支后不执行 `loadList()`,缺料时列表不刷新。建议:缺料按 200 + 业务结果返回,由前端以警告态展示并刷新列表 |
+| O-A02 | 齐套 `sqty` 跨全部库位汇总,不区分工单 `LocationFrom`(如工单从 5007 领料,却把 1000 等库位的库存一并计入可用量);即使 D-A03 的同步补齐,口径仍偏乐观 |

+ 135 - 0
doc/plan/UAT留证/2026-08-09-S1起真人测试/README.md

@@ -0,0 +1,135 @@
+# 2026-08-09 S1 起真人端到端测试(留证索引)
+
+| 项 | 内容 |
+|----|------|
+| 模式 | **A 新建主链路**(非存量点检) |
+| 起始模块 | S1 产销协同 → 后续接 S2 / MES·WMS |
+| 状态 | 🔄 进行中(S1 主链路已过;S2 已记;**WMS 备料 → 发料主链路已完整跑通**;Ai-DOP 齐套回写已验;**MES 报工链路 + `ActualTime` + 冻结期保护已验**)。缺陷:D-W01、D-A01(已修)、D-A02、D-A03、D-A04、D-M01 |
+| 维护日期 | 2026-08-11 |
+
+---
+
+## 1. 环境
+
+| 项 | 值 |
+|----|-----|
+| 前端 | `http://127.0.0.1:8888` |
+| 后端 | `http://127.0.0.1:5005` |
+| 数据库 | MySQL `aidopdev`(租户 AIDOP) |
+| 租户 | `797403760988229`(登录选 **AIDOP**) |
+| 账号 | `AIDOPDemo` / `1234567890dop` |
+
+---
+
+## 2. 本轮推荐主数据(已库内核验)
+
+### 2.1 主用物料(推荐)
+
+| 字段 | 值 |
+|------|-----|
+| 物料编码 | **`91HC0497`** |
+| 名称 | 超声高频外科集成手术设备主机-OP9-中国 |
+| 可销售 / 可制造 | 1 / 1 |
+| BOM | 1 张头,`ic_bom_child` **11** 行 |
+| 工艺 `RoutingOpDetail` | **36** 道(`RoutingCode=91HC0497`,含 MilestoneOp) |
+| 选用理由 | 主数据齐全;此前 MES 联调工单 `M500000004` 即此物料,后续报工/热回读可衔接 |
+
+### 2.2 备用物料
+
+| 物料 | BOM 子件 | 工艺道数 | 备注 |
+|------|----------|----------|------|
+| `91H0DE3` | 11 | 19 | 可销售/可制造;工艺较短,主料异常时换用 |
+
+### 2.3 客户
+
+| 字段 | 值 |
+|------|-----|
+| 客户编码 | `10000000` |
+| 客户名称 | 瑞贝德(与近期真人单一致) |
+
+### 2.4 建议建单数量
+
+| 字段 | 建议值 | 说明 |
+|------|--------|------|
+| 行数 | **1 行** | 先保证「有 BOM+工艺」闭环,少干扰 |
+| 数量 | 初测 2 → **若要出工单需加大**(建议 ≥50) | 评审逻辑:**有缺料才建工单**;qty=2 时资源检查不缺料 → 新建工单 0 |
+| 客户要求交期 | **必填**(明细 `plan_date`) | 未填则订单评审直接 400「缺少计划交期」;建议填约 +14~30 天 |
+
+---
+
+## 3. 本轮单号对照(随测填写)
+
+| 对象 | 单号 / Id | 备注 |
+|------|-----------|------|
+| 销售订单 `bill_no` | **`SO202608100001`** | 类别「计划」 |
+| 销售订单 `Id` | `836102349119557` | |
+| 订单行 `entry_id` | `836102349717573` | 现 qty=**50**,progress=2;plan=`2026-08-24`,sys_capacity=`2026-09-05` |
+| 工单 `WorkOrd` | **`M500000009`** | Item=`91HC0497`,QtyOrded=**15**(净缺料),Detail=55,Routing=36 |
+| Domain | **`8010`** | MES Outbox 已入队 `WO_MES_WOR`(status=1) |
+| 领料单 `Nbr` | **`SM20260810001`** | 55 行;WMS APP 账号 `qm08` |
+| 发料成功行 | **Line 1 / `A2C3115`** | `NbrDetail.RecID=247876`;`QtyOrd=250`、`QtyRec=**50**`;转出仓 `5007` |
+| 发料用标签 | **`A2C3115-20260811-UAT01`** | `MissedPrint.RecID=126640`;APP「外部标签初始化」建,140 → 发后 90 |
+| 受阻行(本轮放弃) | Line 2 / `1A03N54` | `RecID=247877`;标签 `1A03N54-20260809-AIDOP0001`(`RecID=126634`);已退回,受缺陷 D-W01 阻塞 |
+| 遗留待清理 | 标签 `1A03N54-20260811-UAT01`(50 @ 1001,无库存支撑);`SAPInv` 补数行(`CreateUser=AIDOPUAT`) | 见 03 §2.6 / §3A.1 |
+| MES 测试工单 / 工序 | **`M500000004`** / Op **601** | 物料 `91HC0497`,产线 **ZJ02**;APP 账号 `ZJ01`/`111`,员工 `RQTJ1156` |
+| MES 产线会话 | `LineStatusDet.RecID=18563` | `BusinessID=3511345`、`UInt2=1`、`Period=0`;结束计划后已释放 |
+| MES 计划行(165) | 排产行 `3511345`(`Period=1`)/ 影子行 `3511410`(`Period=0`) | 实绩落影子行 → D-M01 |
+| MES 报工用在制品标签 | **`CP0120260810ZJ0100001`** | 5 件 @`5008`;`Op` 由 501 就地推进至 601 |
+| Ai-DOP 全量重排 runId | **`836513414926405`** | 04:01:50–04:04:55(185 s),冻结工单 0 / 工序 **2** |
+
+---
+
+## 4. 文档与目录
+
+| 文件 | 说明 |
+|------|------|
+| [01-S1-步骤记录.md](./01-S1-步骤记录.md) | S1 逐步操作、预期、实际、截图文件名、缺陷 |
+| [02-S2-步骤记录.md](./02-S2-步骤记录.md) | S2 全量重排 / 可执行日计划 / 排产异常(P-026·P-028,server `1.0.323`) |
+| [03-WMS-步骤记录.md](./03-WMS-步骤记录.md) | WMS APP 生产备料 / 发料确认(领料单 `SM20260810001`,账号 `qm08`) |
+| [04-MES-步骤记录.md](./04-MES-步骤记录.md) | MES APP 投产→上岗→运行→扫码报工→离岗→结束计划验 `ActualTime`;Ai-DOP 全量重排验冻结期保护(工单 `M500000004`/Op 601,产线 ZJ02) |
+| [screenshots/](./screenshots/) | 截图;命名 `S1-NN-简述.png` / `S2-NN-简述.png` / `WMS-NN-简述.png` |
+| [汇总.md](./汇总.md) | 全模块测完后整理(当前骨架) |
+
+**截图原则(已约定)**:不逐步截;只在**关键步骤**且页面上带有**关键测试数据**(单号、物料、数量、评审结果、工单号等)时截一张即可。纯打开列表/空态可不截。
+
+上游指南:[S1-S4_UAT场景测试数据说明与测试指南.md](../S1-S4_UAT场景测试数据说明与测试指南.md)
+
+---
+
+## 5. 进度
+
+| 阶段 | 状态 |
+|------|------|
+| 环境就绪 | ✅ 前后端 HTTP 200 |
+| 物料/客户选定 | ✅ |
+| S1 登录 → 建单 → 评审 → 交付 | ✅ 主链路已过(见 01 / 汇总) |
+| S2 全量重排 + 日计划目视 | ✅ 已记(`M500000009` 3 行;见 02) |
+| S2 冻结 / MES APP | ✅ 已过(见 04);S2 幂等 ⬜ 待续 |
+| WMS 生产备料 | ✅ 已过(两轮:`1A03N54` 01:24、`A2C3115` 02:07) |
+| WMS 备料退回 | ✅ 已过(01:29,三处字段完整回滚) |
+| WMS 外部标签初始化 | ⚠️ 半通过:`A2C3115@5007` ✅;`1A03N54@1001` ❌ → **缺陷 D-W01**(只建标签不建库存) |
+| WMS 发料确认 | ✅ **已过**(02:08;库存 50→0、`WorkOrdDetail.QtyPosted=50`、`QadTracking` 已入 ERP 推送队列) |
+| Ai-DOP 工单下达列表可见范围 | ✅ 已修并验证(**D-A01**:原写死 `Status='p'`,已下达工单查不到 → 改为可多选状态筛选,默认 `p`+`r`) |
+| Ai-DOP 齐套回写 | ✅ **已验**(02:45 真人点「单工单齐套」;新 `b_examine_result`=`836494557945925`、旧记录置失效、`MaterialSituation` 已重写)。但暴露 **D-A02 / D-A03**,结论口径不可信 |
+| Ai-DOP 齐套库存口径 | ❌ **D-A03**:读 aidopdev 静态 `InvMaster`(2025 年数据),WMS 今晚 `+50/−50` 实时过账完全未传导 |
+| 齐套两入口一致性 | ❌ **D-A02**:排产(扣占用,49 行缺料)与单工单(不扣占用,3 行缺料)结论相反,且后者会覆盖前者 |
+| MES 投产 → 上岗 → 运行 → 扫码报工 → 离岗 → 结束计划 | ✅ **全链路跑通**(03:14–03:55,工单 `M500000004`/Op 601/产线 ZJ02;见 04) |
+| MES 工序扫码流转(501 → 601) | ✅ 已过(扫 `CP0120260810ZJ0100001`,标签 `Op` 就地 501→601,报工 5 件) |
+| MES `ActualTime`(先离岗后结束计划) | ✅ **已验**:`1574` 秒 = **26.23 分钟**,与实际运行时长一致;澄清其为「状态切换增量累加人工时」而非结束时汇总,故**必须先离岗** |
+| MES 冻结期保护(工序级) | ✅ **已验**:全量重排 `runId=836513414926405`「冻结工单 0/**工序 2**」;501/601 原计划日与 `IsActive` 完全保留,异常表新增 2 条「现场已投产」,704 按游标规则重排至 `2026-08-12`(并由 `2028-01-07` 提前) |
+| MES 计划 vs 实际口径 | ❌ **D-M01**:执行实绩(`CompQty`/`ActualTime`/`SetupTime`)全部落在投产新建的 `Period=0` 影子行,Ai-DOP 排产行恒为 0 |
+
+### 5.1 本轮缺陷 / 补数登记
+
+| 编号 | 类型 | 摘要 |
+|------|------|------|
+| **D-W01** | 旧 DOP WMS 缺陷 | `pr_WMS_SaveUnknownLabelInit` 记账段把标签总量子查询关联到 `LocationDetail` 而非入参临时表,导致零库存的「物料+库位」被 `where` 过滤 → **该功能永远建不出首行库存**。改 `on d.Location=m.Location` → `on t.Location=m.Location` 可修。**已转缺陷报告**(`doc/plan/旧DOP-MES-WMS/缺陷-D-W01-未知标签初始化.md`)+ UAT 绕行办法;Ai-DOP 侧不改 165 |
+| **D-A01** | Ai-DOP 缺陷(**本轮已修**) | 工单下达列表 `GetDispatchList` 写死 `a.Status='p'`,已下达工单查不到 → 行级「单工单齐套」「MES补偿建领料」对 `r` 工单不可达,状态列/领料单号列成死代码。已改为按入参多选状态筛选(默认 `p`+`r`),非 `p` 行禁用「工单下达」。详见 03 §2.12 |
+| **D-A02** | Ai-DOP 缺陷 | 齐套两入口口径不一致且互相覆盖:排产传 `bangId` 扣 `ic_item_stockoccupy` 占用(49 行缺料),单工单传 `bangId=0` 不扣(3 行缺料);后者执行时把前者记录置 `IsDeleted=1`,排产结论丢失且无法分辨当前状态出自哪种口径。详见 03 §2.15。**代码已落地**(WP-C,待重启验收) |
+| **D-A03** | 集成链路缺失 | 齐套 `sqty` 取自 aidopdev `InvMaster`(`UpdateTime` 停在 2025-12-31),与 MES 库 `dopdemorq.InvMaster` 已分叉(`A2C3115@5007` 83553 vs 83543、`@1000` 5000 vs 0);今晚 `rct-unp +50`、`iss-wo −50` 两条真实流水在 Ai-DOP 侧无体现。代码有 `S3_INVENTORY` 同步定义但链路未运行。详见 03 §2.14。**代码已落地**(WP-B 直读 165,待重启验收) |
+| **D-A04** | 既存缺陷(与本轮无关) | `MdpOutboxDeadLetterAlertJob.cs:50` 抛 `Field "_lastSeenDeadMaxId" can't be private`,定时任务每 5 分钟失败,Outbox 死信告警从未生效。详见 03 §2.16。**代码已落地**(WP-A,待重启验收) |
+| **D-M01** | 旧 DOP MES 缺陷 | 同一次投产的执行数据被劈成两条计划行:投产时 APP 新建 `Period=0` 影子行(`OrdQty=0`),报工/离岗按 APP 传入 `@Period=0` 写影子行(`CompQty=5`、`ActualTime=1574`、`SetupTime=0.11417`),而结束计划按 `LineStatusDet.UInt2=1` 写 Ai-DOP 排产行(只写 `Status=S`)→ 排产行实绩恒为 0,「计划 vs 实际」口径断裂。已核实进度看板与冻结期保护不受影响。详见 04 §3.1。**代码已落地**(WP-D 工序级合并到 `ado_psd_op_actual`,待重启 + 热关注补登记验收) |
+| O-M01~O-M07 | 观察项 | 工序强制扫码规则不由工序主数据表达(三个开关全 `false`,由 `LotSerialControl` 隐式推导)、ZJ02 无员工绑定致上岗列表为空、`生产速度` 除零显示 `NaN`、`标准运行` 恒为 0(`StdRunTime` 空)、`ProdTime2` 的 300 秒门槛与分段语义、`ActualTime`(秒)/`SetupTime`(小时) 单位混用、全量重排 `runId` 进度文本被截断。详见 04 §4 |
+| O-W01~O-W06 | 观察项 | 备料页右侧列需横向拖动、物料卡强依赖蓝牙打印机、菜单分散在「其他」页签、扫描数默认带整标签数量等,详见 03 §5 |
+| O-A01~O-A02 | 观察项 | 齐套「缺料」以 HTTP 400 + 红色错误提示返回(与真实故障同形,且缺料时列表不刷新);`sqty` 跨全部库位汇总、不区分工单 `LocationFrom`。详见 03 §5 |
+| 环境补数 | SAP 镜像 | `SAPInv` 增 1 行(`A2C3115` / 空批次 / `5007` / `LABST=50` / `CreateUser=AIDOPUAT`);演示环境无 SAP 接口同步,WMS 侧实物库存仍全部由 APP 真实操作产生 |

+ 136 - 0
doc/plan/UAT缺陷整治任务书/README.md

@@ -0,0 +1,136 @@
+# UAT 缺陷整治任务书(2026-08-11 起)
+
+| 项 | 内容 |
+|----|------|
+| 来源 | [`doc/plan/UAT留证/2026-08-09-S1起真人测试/`](../UAT留证/2026-08-09-S1起真人测试/README.md) 真人 UAT 实测暴露的缺陷 |
+| 定位 | 把 4 组缺陷拆成 4 个**可独立派发、可独立验收**的工作包,交给不同执行者(人或模型)按篇执行 |
+| 编写日期 | 2026-08-11 |
+| 必读配套 | [`00B-执行须知(模型输入契约)`](../旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md)、[`00-总体方案`](../旧DOP-MES-WMS/对接任务书/00-总体方案.md) |
+| 状态 | **代码已落地**(2026-08-11):WP-A/B/C/D 均已按定案版改完;待重启后端跑迁移脚本并做业务验收 |
+| 粒度 | **定案版**:每篇都已把设计决策拍死(`0. 本包定案` 章),正文给出逐文件定位锚点 + 替换后代码 + 完整 DDL。执行者**不需要再做设计选择**,照改即可 |
+
+---
+
+## 1. 工作包一览
+
+| 包 | 主题 | 对应缺陷 | 能否立即开工 | 前后端 | 预估 |
+|----|------|---------|------------|-------|------|
+| [WP-A](./WP-A-两项小修.md) | 死信告警任务修复 + 标签初始化缺陷归档 | D-A04、D-W01 | **A1 可以**;A2 只出文档 | 后端 | 0.5 天 |
+| [WP-B](./WP-B-库存新鲜度打通.md) | 让齐套算料读到 165 WMS 实时库存 | D-A03 | 可以(4 个决策项已定案) | 后端 | 3–5 天 |
+| [WP-C](./WP-C-齐套口径统一.md) | 两个齐套入口口径一致 + 不互相覆盖 | D-A02、O-A01 | 可以(7 个决策项已定案;结论可信度依赖 WP-B) | 前后端 | 2–3 天 |
+| [WP-D](./WP-D-计划实绩合并.md) | 让排产行能对比计划与实际 | D-M01 | 可以(含一段入站回读,工作量比预想大) | 后端 | 3–4 天 |
+
+每篇的结构一致,执行者按序读即可:`0. 本包定案`(决策 + 推翻条件)→ `缺陷事实`(已核实,附可复跑 SQL)→ `第 1 步 事实复核`(先跑一遍确认环境没变)→ 逐步骤改动(定位锚点 + 替换后代码)→ `验收` → `风险与回滚` → `明确不做` → `版本号`。
+
+## 2. 执行顺序与依赖
+
+```mermaid
+flowchart LR
+  A1["WP-A1 死信告警<br/>独立,随时可做"]
+  A2["WP-A2 D-W01 归档<br/>独立,纯文档"]
+  B["WP-B 库存新鲜度<br/>D-A03"]
+  C["WP-C 齐套口径统一<br/>D-A02"]
+  D["WP-D 计划实绩合并<br/>D-M01"]
+  B -->|"验收数字可信的前提"| C
+  A1 -.->|无依赖| B
+  A2 -.->|无依赖| C
+  D -.->|与 B/C 无耦合,可并行| C
+```
+
+**建议排法**:WP-A1 先做(半小时内可完成并验证)→ WP-B 与 WP-D 并行(两拨人/两个会话,改动文件不重叠)→ WP-B 落地后再做 WP-C。
+
+**为什么 WP-C 排在 WP-B 之后**:WP-C 解决的是「两个入口算法不一致」,但两个入口目前读的都是 2026-05-28 就停更的静态库存。先做 WP-C 只能做到「两个都一样地错」,验收时无法判断数字对不对。若人力允许可并行开发,但**验收必须等 WP-B 上线后重跑**。
+
+## 3. 共用硬约束(违反即回退)
+
+除 [`00B` §7](../旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md) 的全部禁止事项外,本轮特别强调:
+
+| # | 约束 | 说明 |
+|---|------|------|
+| C1 | **不改 165(`dopdemorq`)的任何结构** | 表、存储过程、触发器、索引、作业全部只读。D-W01 与 D-M01 的根因都在 165 的存储过程里,**不得直接改**,只能在我方侧规避或归档给归属方 |
+| C2 | **不在新系统调用或新建存储过程** | 旧逻辑一律 C# 重写 |
+| C3 | **不新增旧 DOP 没有的自动闭环** | 例如「齐套不足自动触发采购」属新能力,需另立需求 |
+| C4 | **不做超出本包范围的改动** | 发现相邻问题写进交付说明,不要顺手改。本目录每篇都有「明确不做」章 |
+| C5 | **造数必须打标** | 沿用 `AIDOPUAT` / `AIDOP_GU` 前缀,便于事后清理 |
+| C6 | **禁止把凭据写进仓库** | `dopsa` 密码已在 `mdp_source.db_password_enc` 中配置好,不要落到代码、脚本或文档里 |
+
+## 4. 版本号与脚本号(开工前先核对,可能已被别人占用)
+
+| 项 | 当前值 | 位置 |
+|----|-------|------|
+| 后端版本 | **1.0.327** | `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj`(`<Version>`/`<AssemblyVersion>`/`<FileVersion>` 三处同号) |
+| 前端版本 | **2.4.285** | `Web/package.json` 的 `version` |
+| UpdateScript 最大号 | **1.0.323** | `server/Admin.NET.Web.Entry/UpdateScripts/` |
+
+**已分配脚本号**(若开工时发现已被占用,取下一个未占用且 ≤ 当前后端版本的号,并回来改这张表与对应任务书):
+
+| 包 | 脚本 | 内容 |
+|----|------|------|
+| WP-A | 无 | 只改一个 `.cs` 文件 |
+| WP-B | 无 | 配置 + 新增两个类,不动库结构 |
+| WP-D | `1.0.324.sql` + `1.0.324.verify.sql` | 修 `mdp_entity.biz_key_expr`、建 `ado_psd_op_actual` |
+| WP-C | `1.0.325.sql` + `1.0.325.verify.sql` | `b_examine_result` 补 `check_scope` / `net_scope` |
+
+### 迁移脚本的四条硬约束
+
+来自 [`server/Admin.NET.Core/Update/AutoVersionUpdate.cs`](../../../server/Admin.NET.Core/Update/AutoVersionUpdate.cs),违反会导致**脚本静默不执行**或**后端启动直接失败**:
+
+| # | 约束 | 后果 |
+|---|------|------|
+| S1 | 脚本文件**行尾必须 LF** | CRLF 改变 SHA256;与 `sys_db_migration_log.file_hash` 不一致时抛 `禁止静默覆盖` 并阻止启动 |
+| S2 | 脚本号**必须 ≤ `.csproj` 的 `<Version>`** | 执行器只取 `ParsedVersion <= currentVersion` 的脚本;号大于当前版本的脚本会被**跳过且无任何报错**。所以「先建 1.0.330.sql 再把版本升到 1.0.328」= 脚本永不执行 |
+| S3 | 脚本内**不得出现 `DELIMITER` 字样**(含注释) | 执行器直接抛异常拒绝 |
+| S4 | 已 `Success` 的脚本**不可再改内容** | hash 校验失败并阻止启动;要修正必须新建更高号脚本 |
+
+两点便利机制可以放心用:脚本含 `SET @` / `PREPARE` 时执行器会**整段同连接执行**(不按 `;` 拆分),因此 `information_schema` + `PREPARE` 的幂等 DDL 写法有效;同名 `<版本>.verify.sql` 会在主脚本成功后**逐条执行并要求每条返回真值**,用来自证 DDL 生效。
+
+版本号递增规则见 [`version-bump-on-commit.mdc`](../../../.cursor/rules/version-bump-on-commit.mdc):**只按本次提交实际纳入的文件所属端**递增 patch,纯文档提交不递增。四个包对应:WP-A / WP-B / WP-D 只升后端,WP-C 前后端都升。
+
+## 5. 环境与连接
+
+| 库 | 连接 | 权限 | 用途 |
+|----|------|------|------|
+| 旧 MES/WMS | SQL Server `123.60.180.165:1433` / `dopdemorq` | `dopsa`;**本轮 4 个包全部只需只读** | 抽数、核对 |
+| 新系统 | MySQL `123.60.180.165:3306` / `aidopdev` | 读写 | 业务与中台表 |
+
+`mdp_source` 中 `DOPDEMORQ_SQLSERVER`(`id=12`)**已配好账号密码,健康检查 OK**(2026-08-10 20:24 `health_msg='OK'`)——**不是**阻断项,不要再去要连接串。
+
+## 6. 每个包的交付物
+
+无论哪个包,交付时必须同时给出:
+
+1. **代码改动**(含对应端版本号递增)
+2. **自检结果**:`dotnet build` 通过;`rg -n "EXEC\s+pr_|CALL\s+pr_" server/ Web/` 无命中
+3. **验收证据**:把该包「验收标准」章的每条 SQL/命令的**实际输出**贴进交付说明
+4. **留证回填**:在 [`UAT留证 README`](../UAT留证/2026-08-09-S1起真人测试/README.md) 的缺陷登记表对应行补「修复情况」,并在原始步骤记录(`03-WMS-步骤记录.md` / `04-MES-步骤记录.md`)相应小节末尾追加一行「→ 已修复,见 WP-x」
+5. **未决事项**:本包遇到但未处理的问题,逐条列出,不要私自扩范围
+
+## 7. 派发给执行模型的方式
+
+一个会话只派**一个包**(包内步骤有顺序依赖,混派会互相踩文件)。派发话术照抄:
+
+```text
+请执行 doc/plan/UAT缺陷整治任务书/WP-<X>-<名>.md,并先读 doc/plan/旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md。
+规则:
+1. 先做该篇「第 1 步:事实复核」,把每条 SQL 的实际输出贴出来。若与文档所述不一致,停下来报告,不要按文档继续改。
+2. 按「定位锚点 → 替换为」逐处改,锚点找不到就停下报告,不要凭猜测改别处。
+3. 该篇「0. 本包定案」里的决策不要重新讨论;只有命中该章写明的「推翻条件」才提出异议。
+4. 迁移脚本必须 LF 行尾、脚本号不得大于 .csproj 当前版本、脚本内不得出现 DELIMITER 字样。
+5. 改完跑 dotnet build(含前端改动的还要跑前端构建),再按「验收」章逐条执行并贴实际输出。
+6. 同一提交内按该篇「版本号」章递增对应端版本号。
+7. 「明确不做」章列的事一件都不要做;发现的相邻问题写进交付说明。
+```
+
+**跨包并行注意**:WP-B 与 WP-C 都会改 `MaterialRequirementCalculator.cs`(WP-B 改取数来源、WP-C 改口径参数),并行开发时以 WP-B 先落地为准,WP-C 在其之上改;详见 [WP-B §9](./WP-B-库存新鲜度打通.md)。WP-D 与 B/C 无文件重叠,可完全并行。
+
+## 8. 本轮不在任务书内的遗留事项
+
+以下项已识别但**未写成任务书**,需要时另立:
+
+| 项 | 说明 | 出处 |
+|----|------|------|
+| 补 `WorkOrdRouting.StdRunTime` 主数据 | 纯数据维护,无需改代码;不补则标准工时对比与生产速度显示异常(`NaN`) | O-M05 / O-M06 |
+| 清理 UAT 造数 | `MissedPrint` 的 `1A03N54-20260811-UAT01`、`SAPInv` 的 `AIDOPUAT` 行等 | 03 §2.6 / §3A.1 |
+| 继续 S2 幂等性等场景测试 | 重复排产是否重复建行、工单关闭、看板数字核对 | — |
+| `PeriodSequenceDet` 历史行堆积 | 单张工单 `M500000004` 已累积 39 行(`IsActive=0` 38 行),每次重排新增一批 | 本轮查库发现,见 WP-D §9 |
+| 其余 3 处直读 legacy `InvMaster` 的代码 | WP-B 只切齐套算料一条,其余排期 | WP-B §9 |

+ 211 - 0
doc/plan/UAT缺陷整治任务书/WP-A-两项小修.md

@@ -0,0 +1,211 @@
+# WP-A|死信告警作业修复 + WMS 未知标签初始化缺陷报告
+
+> **状态:定案版(可直接执行)**。本文所有决策已锁定,无需再向任何人确认。
+> 执行者:具备 C# / .NET / MySQL 基础的模型或工程师。预计 1–2 小时(含验证)。
+> 前置:无。可与 WP-B / WP-C / WP-D 并行。
+
+---
+
+## 0. 本包定案
+
+| 项 | 定案 | 推翻条件 |
+|---|---|---|
+| A-1 D-A04 修复方式 | 把 `_lastSeenDeadMaxId` 读到**方法内局部变量**再进表达式树,**不**改字段可见性、**不**改作业周期、**不**改告警文案 | 无。这是 SqlSugar 表达式树的硬限制,其他写法都绕不开 |
+| A-2 D-W01 处理方式 | **只出缺陷报告 + UAT 绕行办法**,不改 165 任何存储过程/表 | 只有在负责人书面授权修改 165 后才可动 |
+
+---
+
+## 1. 目标
+
+1. **D-A04**:让定时任务 `job_mdp_outbox_dl_alert` 每 5 分钟正常跑完,不再抛 `SqlSugarException`。
+2. **D-W01**:把「WMS 未知标签初始化不产生初始库存」写成可移交给 165 侧维护方的缺陷报告,并给 UAT 现场一个可立即用的绕行办法。
+
+**本包不涉及**:库存新鲜度(WP-B)、齐套口径(WP-C)、计划实绩合并(WP-D)。
+
+---
+
+## 2. 任务 A-1:修复 Outbox 死信告警作业(D-A04)
+
+### 2.1 现象与证据
+
+作业每次执行都失败,日志与 `sys_log_ex` 里的异常:
+
+```
+SqlSugarException: Field "_lastSeenDeadMaxId" can't be private
+```
+
+原因:SqlSugar 把 lambda 解析成表达式树时,对**闭包捕获的私有静态字段**无法取值。代码里 `_lastSeenDeadMaxId` 被直接写进了 `CountAsync` 的条件表达式。
+
+### 2.2 唯一改动点
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxDeadLetterAlertJob.cs`
+
+**定位锚点**(当前代码,位于 `ExecuteAsync` 内,紧跟 `maxDeadId` 查询之后):
+
+```csharp
+        var maxDeadId = await db.Queryable<MdpOutbox>()
+            .Where(x => x.Status == 2)
+            .MaxAsync(x => (long?)x.Id, stoppingToken) ?? 0L;
+        var newDeadById = maxDeadId > _lastSeenDeadMaxId
+            ? await db.Queryable<MdpOutbox>()
+                .CountAsync(x => x.Status == 2 && x.Id > _lastSeenDeadMaxId, stoppingToken)
+            : 0;
+```
+
+**替换为**:
+
+```csharp
+        var maxDeadId = await db.Queryable<MdpOutbox>()
+            .Where(x => x.Status == 2)
+            .MaxAsync(x => (long?)x.Id, stoppingToken) ?? 0L;
+        // SqlSugar 表达式树不能取私有静态字段的值,先落到局部变量再进 lambda
+        var lastSeenDeadMaxId = _lastSeenDeadMaxId;
+        var newDeadById = maxDeadId > lastSeenDeadMaxId
+            ? await db.Queryable<MdpOutbox>()
+                .CountAsync(x => x.Status == 2 && x.Id > lastSeenDeadMaxId, stoppingToken)
+            : 0;
+```
+
+**同一方法内后续的游标推进也改为用局部变量比较**(当前代码):
+
+```csharp
+        _lastScanUtc = DateTime.UtcNow;
+        if (maxDeadId > _lastSeenDeadMaxId)
+            _lastSeenDeadMaxId = maxDeadId;
+```
+
+**替换为**:
+
+```csharp
+        _lastScanUtc = DateTime.UtcNow;
+        if (maxDeadId > lastSeenDeadMaxId)
+            _lastSeenDeadMaxId = maxDeadId;
+```
+
+### 2.3 不要做的事
+
+- **不要**把 `_lastSeenDeadMaxId` 改成 `public` 或 `internal`:字段是进程内游标,暴露出去会被误用。
+- **不要**改成 `db.Ado.SqlQuery` 手写 SQL:本处 `Queryable` 语义正确,只是取值方式违规。
+- **不要**动 `AlertCooldown`(30min)、`StalePendingThreshold`(15min)、`PeriodSeconds(300)`、`NoticeReceiverUserId`。
+- **不要**顺手重构 `SysNotice` 写入逻辑(注释已说明后台作业无登录态、故意不走 `SysNoticeService`)。
+
+### 2.4 验证
+
+```powershell
+# 1) 编译
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+```
+
+2)启动后端,等 6 分钟(触发一次周期),或在「系统管理 → 定时任务」里手动执行 `job_mdp_outbox_dl_alert`。
+
+3)确认无异常:
+
+```sql
+-- 期望:0 行
+SELECT id, exception_msg, create_time
+FROM sys_log_ex
+WHERE exception_msg LIKE '%_lastSeenDeadMaxId%'
+  AND create_time >= NOW() - INTERVAL 30 MINUTE;
+```
+
+4)确认作业执行成功(Furion Schedule 落库表):
+
+```sql
+-- 期望:最近一条 status 非失败;若 sys_job_* 表名不同,改用「定时任务」页面看「最近执行结果」
+SELECT * FROM sys_job_detail WHERE job_id = 'job_mdp_outbox_dl_alert';
+```
+
+5)功能未回退:人为造一条死信后应能收到站内通知。
+
+```sql
+-- 造数(UAT 库可用;生产禁止)
+INSERT INTO mdp_outbox (tenant_id, status, create_time, update_time)
+VALUES (797403760988229, 2, NOW(), NOW());
+-- 等下一次 5 分钟周期,期望 sys_notice 出现 Title='Outbox死信/积压告警' 的新行
+SELECT id, title, LEFT(content, 120) AS content, create_time
+FROM sys_notice WHERE title = 'Outbox死信/积压告警' ORDER BY id DESC LIMIT 3;
+```
+
+> 若 `mdp_outbox` 有非空约束导致上面 INSERT 失败,改为把一条已有行临时 `UPDATE ... SET status = 2`,验证完再还原。
+
+### 2.5 验收标准(A-1)
+
+- [ ] 编译通过,无新增告警。
+- [ ] 连续 3 个周期(15 分钟)内 `sys_log_ex` 无 `can't be private` 异常。
+- [ ] 造一条死信后 30 分钟冷却窗口内收到且仅收到 1 条站内通知(冷却逻辑未被破坏)。
+- [ ] `git diff` 只含 `MdpOutboxDeadLetterAlertJob.cs` 一个文件、约 4 行改动。
+
+### 2.6 版本号(强制)
+
+本改动属**后端代码**,必须在同一提交内把 `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>` **三处同号 patch +1**。**不要**动 `Web/package.json`(本包不含前端改动)。
+
+---
+
+## 3. 任务 A-2:WMS 未知标签初始化缺陷报告(D-W01)
+
+### 3.1 硬约束
+
+**不得修改 165 环境的任何存储过程、表结构、数据**。165 属对接方(旧 DOP-MES-WMS)资产,本项目只读。因此本任务的交付物是**文档**,不是代码。
+
+### 3.2 缺陷事实
+
+- 环境:165 SQL Server 库 `dopdemorq`
+- 入口:WMS「未知标签初始化」功能
+- 存储过程:`pr_WMS_SaveUnknownLabelInit`
+- 现象:对**该物料+库位组合在 `InvMaster` 中尚无记录**的情况,执行初始化后**不产生任何初始库存行**,页面无报错,静默失败。
+- 影响:UAT 中新条码/新库位无法建立起始库存,导致后续出入库、齐套检查的库存基数为 0。
+- 复现步骤见 `doc/plan/UAT留证/2026-08-09-S1起真人测试/03-WMS-步骤记录.md` 中 D-W01 段落。
+
+### 3.3 根因
+
+存储过程中新增库存行的分支,把「目标库位是否已存在库存」的子查询写成了 `JOIN` 参与筛选。当目标组合**不存在**时,该 `JOIN` 无匹配行 → 结果集为空 → `INSERT ... SELECT` 插入 0 行。也就是说:**只有已经有库存的组合才能被「初始化」**,与功能语义相反。
+
+### 3.4 建议修法(供 165 维护方评估,本项目不执行)
+
+把该分支从「JOIN 现存库存」改为「NOT EXISTS 现存库存则插入」,即:
+
+```sql
+-- 建议方向(伪码,需由 165 维护方按其实际 SP 上下文落地)
+INSERT INTO InvMaster (Domain, ItemNum, Location, LotSerial, QtyOnHand, AvailStatusQty, ...)
+SELECT @Domain, @ItemNum, @Location, @LotSerial, @Qty, @Qty, ...
+WHERE NOT EXISTS (
+    SELECT 1 FROM InvMaster m
+    WHERE m.Domain = @Domain AND m.ItemNum = @ItemNum
+      AND m.Location = @Location AND ISNULL(m.LotSerial, '') = ISNULL(@LotSerial, '')
+);
+
+-- 已存在则走累加分支
+UPDATE InvMaster
+SET QtyOnHand = QtyOnHand + @Qty, AvailStatusQty = AvailStatusQty + @Qty, ...
+WHERE Domain = @Domain AND ItemNum = @ItemNum
+  AND Location = @Location AND ISNULL(LotSerial, '') = ISNULL(@LotSerial, '');
+```
+
+同时建议:初始化 0 行时**抛业务错误**而非静默返回,避免现场以为成功。
+
+### 3.5 UAT 现场绕行办法(本项目可用,二选一)
+
+**办法一(推荐)**:先用 WMS 已有的**正常入库**流程给该物料+库位建立第一笔库存,之后再用「未知标签初始化」补差异。已存在库存的组合走累加分支,功能正常。
+
+**办法二**:由 165 DBA 手工在 `InvMaster` 插入一条 `QtyOnHand = 0` 的占位行,再执行未知标签初始化。此办法需 165 侧授权,本项目不代为执行。
+
+### 3.6 交付物
+
+在 `doc/plan/旧DOP-MES-WMS/` 下新建 `缺陷-D-W01-未知标签初始化.md`,内容 = 本节 3.2–3.5 全文(可直接搬运),并在文首标注:
+
+- 提出方:Ai-DOP 项目组
+- 提出日期:填写实际日期
+- 期望处理方:165 旧 DOP-MES-WMS 维护方
+- 严重级别:中(有绕行办法,但影响新条码首次建账)
+
+### 3.7 验收标准(A-2)
+
+- [ ] `doc/plan/旧DOP-MES-WMS/缺陷-D-W01-未知标签初始化.md` 已创建,含现象、根因、建议修法、绕行办法四段。
+- [ ] `git diff` 中**没有**任何 165 相关连接串、SQL 迁移脚本、`server/` 代码改动。
+- [ ] UAT 步骤记录里 D-W01 条目补上「已转缺陷报告 + 绕行办法」的结论。
+
+### 3.8 版本号(强制)
+
+A-2 是**纯文档提交**,**不递增**前端或后端版本号。
+
+> 若 A-1 与 A-2 放在同一次提交:因该提交包含后端代码,按 A-1 规则递增后端版本号即可。

+ 591 - 0
doc/plan/UAT缺陷整治任务书/WP-B-库存新鲜度打通.md

@@ -0,0 +1,591 @@
+# WP-B|齐套检查读取实时库存(D-A03)
+
+> **状态:定案版(可直接执行)**。所有决策已锁,无需再确认。
+> 执行者:熟悉 C# / SqlSugar / 多数据源的模型或工程师。预计 0.5–1 天。
+> 前置:无。与 WP-C 都会改 `MaterialRequirementCalculator.cs`,但改的是**不同区段**(见 §9)。
+
+---
+
+## 0. 本包定案
+
+| 编号 | 决策项 | 定案 | 依据 / 推翻条件 |
+|---|---|---|---|
+| B-D1 | 库位范围 | **不变**:跨全部 `Location` 求和 | 本包只解决"数据不新鲜",口径变更是另一件事。一次只改一个变量,否则验收结果无法归因。推翻条件:负责人明确要求齐套只算线边库 |
+| B-D2 | 数量列 | **不变**:`SUM(COALESCE(AvailStatusQty, QtyOnHand, 0))`,不加 `IsActive` / `FreezeQty` / `QtyAllocated` 过滤 | 同上单变量原则。实测 165 侧 16022 行中仅 4 行 `IsActive=0`,影响面极小,是否排除属口径问题另议 |
+| B-D3 | 新鲜度方案 | **同步热读 165**(齐套计算时按物料清单批量查 165),不建冷链同步表、不建定时任务 | 齐套结论必须反映"刚发完料"的库存,任何 N 分钟冷链都不满足。同步读 165 有既有先例:`KeeperResolveService` 就是在下达校验路径上直读 165 的 `EmpWorkDutyMaster` |
+| B-D4 | 本库 `InvMaster` | **保留不动**,不删表、不刷数、不加同步 | 该表仍被其他页面/报表读。让齐套绕开它即可。推翻条件:无 |
+| B-D5 | 租户与账套 | 只有 `tenant_id = 797403760988229`(↔ 账套 `pbxfxp` ↔ `Domain='8010'`)走 165;**其他租户一律回落本库** | 实测本库 `InvMaster` 有两个租户:797403760988229(16022 行,与 165 一一对应)和 824585161322565(2346 行,与 165 无关)。若不加这道闸,第二个租户的齐套会读到别人的库存 |
+| B-D6 | 开关 | 加配置 `AiDOP:Stock:Source`(`Mes` / `Local`),默认 `Mes`;出问题改配置即可回落,不需回滚代码 | 与既有 `AiDOP:PickBill:Mode` 同构 |
+| B-D7 | 批量 vs 逐条 | **批量预取**:一次 BOM 展开的全部物料合并成分块 `IN` 查询 | 现状是每行一次查询(N+1)。改成远程 165 后逐条会变成 N 次跨库往返,排产全量场景会成为性能事故 |
+
+---
+
+## 1. 目标
+
+让齐套检查(单工单、批量、排产内嵌)拿到的可用库存 = **165 当前实时值**。
+
+**验收锚点(已核实的唯一分歧项)**:
+
+| 物料 | 本库 `InvMaster` | 165 实时 | 差异 | 改动后期望 `b_bom_child_examine.sqty` |
+|---|---|---|---|---|
+| `A2C3115` | 88553 | **83543** | −5010 | **83543** |
+| `1A03N54` | 313 | 313 | 0 | 313(不变) |
+| `1AB9355` | 3260 | 3260 | 0 | 3260(不变) |
+| `81HC0795` | 952 | 952 | 0 | 952(不变) |
+| `91DC0198` | 7 | 7 | 0 | 7(不变) |
+| `91HC0497` | 35 | 35 | 0 | 35(不变) |
+
+这组数据的意义:**只有 UAT 期间真正发生过出入库的物料才会失真**,其余保持一致。所以本包的正确性判据非常锐利——`A2C3115` 必须变成 83543,其他五个一个都不许变。
+
+---
+
+## 2. 缺陷事实(已核实)
+
+### 2.1 本库 `InvMaster` 是一次性快照,不随 165 交易更新
+
+```sql
+-- aidopdev
+SELECT COUNT(*) AS total, MAX(UpdateTime) AS max_upd,
+       SUM(CASE WHEN UpdateTime >= '2026-08-01' THEN 1 ELSE 0 END) AS upd_since_aug
+FROM InvMaster WHERE tenant_id = 797403760988229;
+-- 实测:total=16022, max_upd=2025-12-31, upd_since_aug=0
+```
+
+```sql
+-- dopdemorq(165)
+SELECT COUNT(*) AS total, MAX(UpdateTime) AS max_upd,
+       SUM(CASE WHEN UpdateTime >= '2026-08-01' THEN 1 ELSE 0 END) AS upd_since_aug
+FROM InvMaster;
+-- 实测:total=16022, max_upd=2026-08-11 02:08:53.990, upd_since_aug=1
+```
+
+行数完全一致(16022)说明本库是 165 的一次性全量拷贝;`upd_since_aug` 一个是 0、一个是 1,说明 **UAT 期间 165 上发生的那一笔库存变动从未传到本库**。
+
+### 2.2 那一笔变动就是齐套结论失真的来源
+
+```sql
+-- dopdemorq:唯一在 8 月更新过的物料
+SELECT ItemNum, SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)) AS avail, MAX(UpdateTime) AS upd
+FROM InvMaster WHERE ItemNum = 'A2C3115' GROUP BY ItemNum;
+-- 实测:avail=83543, upd=2026-08-11 02:08:53.990
+
+-- aidopdev:同物料的齐套用量
+SELECT c.item_number, c.needCount, c.sqty, c.lack_qty
+FROM b_bom_child_examine c JOIN b_examine_result e ON e.Id = c.examine_id
+WHERE e.morder_no = 'M500000004' AND e.IsDeleted = 0 AND c.item_number = 'A2C3115';
+-- 实测:needCount=4000, sqty=88553, lack_qty=0 —— 用的是快照值,虚高 5010
+```
+
+### 2.3 读库存的唯一入口
+
+```409:431:server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs
+        if (tenantId <= 0)
+            throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
+
+        var qty = await _db.Ado.GetDecimalAsync(
+            """
+            SELECT COALESCE(SUM(
+                CASE
+                    WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
+                    WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
+                    ELSE 0
+                END
+            ), 0)
+            FROM InvMaster
+            WHERE ItemNum = @ItemNum
+              AND tenant_id = @TenantId
+            """,
+```
+
+该方法在本文件内只有**一个**调用点(`ApplySupplyAsync` 第 298 行),改造面很干净。
+
+### 2.4 165 侧库存结构(已核实,可直接依赖)
+
+```sql
+SELECT Domain, COUNT(*) AS cnt, COUNT(DISTINCT ItemNum) AS items,
+       COUNT(DISTINCT Location) AS locs, SUM(CASE WHEN IsActive=1 THEN 1 ELSE 0 END) AS active_cnt
+FROM InvMaster GROUP BY Domain;
+-- 实测:Domain='8010'(唯一), cnt=16022, items=4775, locs=80, active_cnt=16018
+```
+
+只有一个 `Domain`,物料 4775 个,库位 80 个。列名与本库一致(`ItemNum` / `Location` / `AvailStatusQty` / `QtyOnHand` / `Domain` / `IsActive`),所以 SQL 几乎可以照搬,只需换 `tenant_id` 过滤为 `Domain` 过滤。
+
+---
+
+## 3. 第 1 步:事实复核(必须先做)
+
+执行 2.1 / 2.2 / 2.4 的 SQL 并与"实测"值比对。
+
+- 若 `A2C3115` 两侧已一致,说明有人补过数——**换一个物料重新找分歧项**:
+  ```sql
+  -- 在 165 上找出 8 月以后有过交易的物料,作为新的验收锚点
+  SELECT ItemNum, SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)) AS avail, MAX(UpdateTime) AS upd
+  FROM InvMaster WHERE UpdateTime >= '2026-08-01' GROUP BY ItemNum ORDER BY upd DESC;
+  ```
+  然后到本库查同物料的值,把差异对拿来替换 §1 的锚点表。
+- 若两侧行数不再相等(都不是 16022),说明 165 增删过库存行,本包结论不受影响,继续。
+
+---
+
+## 4. 第 2 步:新增配置项
+
+**新建文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopStockOptions.cs`
+
+```csharp
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// 齐套检查的库存读取来源。配置节 <c>AiDOP:Stock</c>。
+///
+/// Source = Mes(默认,直读 165 InvMaster 实时值)| Local(本库 InvMaster 快照,降级用)。
+/// 本库 InvMaster 是 165 的一次性拷贝、不随交易更新(实测 max UpdateTime 停在 2025-12-31),
+/// 故齐套判定默认必须走 Mes;出问题时改配置回落,不需回滚代码。
+/// </summary>
+[OptionsSettings("AiDOP:Stock")]
+public sealed class AidopStockOptions : IConfigurableOptions
+{
+    /// <summary>Mes | Local。</summary>
+    public string Source { get; set; } = "Mes";
+
+    /// <summary>165 账套(InvMaster.Domain)。实测 165 仅有 8010 一个账套。</summary>
+    public string MesDomain { get; set; } = "8010";
+
+    /// <summary>
+    /// 允许走 165 的租户。仅该租户在本库与 165 一一对应(实测同为 16022 行);
+    /// 其他租户(如 824585161322565)的库存与 165 无关,必须回落本库,否则会读到别人的库存。
+    /// </summary>
+    public long MesTenantId { get; set; } = 797403760988229L;
+
+    /// <summary>单次 IN 查询的物料数上限(防止 SQL 过长 / 参数过多)。</summary>
+    public int BatchSize { get; set; } = 500;
+
+    public bool IsMes =>
+        string.Equals(Source?.Trim(), "Mes", StringComparison.OrdinalIgnoreCase);
+}
+```
+
+**注册**:`server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs`
+
+定位锚点:
+
+```csharp
+        services.AddConfigurableOptions<AidopPickBillOptions>();
+```
+
+在其后插入:
+
+```csharp
+        services.AddConfigurableOptions<AidopStockOptions>();
+```
+
+**配置文件**:`server/Admin.NET.Application/Configuration/App.json`
+
+定位锚点:
+
+```json
+  "AiDOP": {
+    "PickBill": {
+      "Mode": "Mes" // WP9 S4:Mes=165 权威建领料;Local=本库降级路径
+    }
+  },
+```
+
+替换为:
+
+```json
+  "AiDOP": {
+    "PickBill": {
+      "Mode": "Mes" // WP9 S4:Mes=165 权威建领料;Local=本库降级路径
+    },
+    "Stock": {
+      "Source": "Mes", // 齐套库存来源:Mes=直读 165 实时;Local=本库 InvMaster 快照(降级)
+      "MesDomain": "8010",
+      "MesTenantId": 797403760988229,
+      "BatchSize": 500
+    }
+  },
+```
+
+---
+
+## 5. 第 3 步:新增 165 库存读取器
+
+**新建文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Order/Mes165StockReader.cs`
+
+```csharp
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// 从 165(DOPDEMORQ_SQLSERVER)批量读取实时可用库存。
+///
+/// 只读、只查 InvMaster;连接经 MdpSourceScopeFactory 走 mdp_source 配置,不新开连接串。
+/// 口径与本库旧实现逐字对齐(跨全部 Location 求和、COALESCE(AvailStatusQty, QtyOnHand, 0)、
+/// 不过滤 IsActive),本包只换数据新鲜度,不动口径。
+/// 分块 IN 查询,避免逐物料跨库往返。
+/// </summary>
+public sealed class Mes165StockReader : ITransient
+{
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public Mes165StockReader(MdpSourceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(Mes165StockReader));
+    }
+
+    /// <summary>按物料号批量取可用库存;返回不含未命中的物料(调用方按 0 处理)。</summary>
+    public async Task<Dictionary<string, decimal>> LoadAvailableQtyAsync(
+        IReadOnlyList<string> itemNumbers,
+        string domain,
+        int batchSize = 500,
+        CancellationToken ct = default)
+    {
+        var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        if (itemNumbers.Count == 0)
+            return result;
+
+        var remote = await _scopeFactory.GetScopeAsync(SourceCode, ct);
+        var size = batchSize <= 0 ? 500 : batchSize;
+
+        for (var offset = 0; offset < itemNumbers.Count; offset += size)
+        {
+            ct.ThrowIfCancellationRequested();
+            var chunk = itemNumbers.Skip(offset).Take(size).ToList();
+
+            var names = new List<string>(chunk.Count);
+            var pars = new List<SugarParameter> { new("@Domain", domain) };
+            for (var i = 0; i < chunk.Count; i++)
+            {
+                names.Add($"@i{i}");
+                pars.Add(new SugarParameter($"@i{i}", chunk[i]));
+            }
+
+            var sql =
+                $"""
+                SELECT ItemNum AS ItemNum,
+                       SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)) AS Qty
+                FROM InvMaster
+                WHERE Domain = @Domain
+                  AND ItemNum IN ({string.Join(",", names)})
+                GROUP BY ItemNum
+                """;
+
+            var rows = await remote.Ado.SqlQueryAsync<StockRow>(sql, pars);
+            foreach (var r in rows)
+            {
+                if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
+                result[r.ItemNum.Trim()] = r.Qty;
+            }
+        }
+
+        _logger.LogInformation(
+            "[Mes165StockReader] domain={Domain} requested={Requested} matched={Matched}",
+            domain, itemNumbers.Count, result.Count);
+
+        return result;
+    }
+
+    private sealed class StockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal Qty { get; set; }
+    }
+}
+```
+
+> `SUM(COALESCE(AvailStatusQty, QtyOnHand, 0))` 与本库旧写法的 `CASE WHEN ... IS NOT NULL` 语义等价,且 SQL Server / MySQL 都支持。
+
+---
+
+## 6. 第 4 步:齐套计算改用批量预取
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs`
+
+### 6.1 注入依赖
+
+**定位锚点**(文件头部):
+
+```csharp
+public class MaterialRequirementCalculator : ITransient
+{
+    private const int MaxBomDepth = 20;
+    private readonly ISqlSugarClient _db;
+
+    public MaterialRequirementCalculator(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+```
+
+**替换为**:
+
+```csharp
+public class MaterialRequirementCalculator : ITransient
+{
+    private const int MaxBomDepth = 20;
+    private readonly ISqlSugarClient _db;
+    private readonly AidopStockOptions _stockOpt;
+    private readonly Mes165StockReader _mesStock;
+
+    public MaterialRequirementCalculator(
+        ISqlSugarClient db,
+        IOptions<AidopStockOptions> stockOpt,
+        Mes165StockReader mesStock)
+    {
+        _db = db;
+        _stockOpt = stockOpt.Value;
+        _mesStock = mesStock;
+    }
+```
+
+文件首行 `using Yitter.IdGenerator;` 之后按需补 `using Microsoft.Extensions.Options;`(若项目已全局 using 则不必;编译报错再加)。
+
+### 6.2 循环前批量取库存基数
+
+**定位锚点**(`ApplySupplyAsync` 内,占用查询之后、逐行循环之前):
+
+```csharp
+        foreach (var line in lines)
+        {
+            if (string.IsNullOrWhiteSpace(line.ItemNumber))
+                continue;
+
+            if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
+            {
+                var rawStock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
+```
+
+**替换为**:
+
+```csharp
+        // 库存基数一次性批量取:MES 权威时读 165 实时值,避免逐行跨库往返
+        var itemNumbers = lines
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNumber))
+            .Select(x => x.ItemNumber!.Trim())
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .ToList();
+        var rawStockMap = await LoadRawStockMapAsync(itemNumbers, tenantId);
+
+        foreach (var line in lines)
+        {
+            if (string.IsNullOrWhiteSpace(line.ItemNumber))
+                continue;
+
+            if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
+            {
+                var rawStock = rawStockMap.TryGetValue(line.ItemNumber, out var rs) ? rs : 0m;
+```
+
+其余行(`var occupied = ...` / `stock = Math.Max(...)` / `stockMap[...] = stock;`)**保持原样不动**。
+
+### 6.3 新增预取方法
+
+**在 `QueryStockQtyAsync` 方法之前插入**:
+
+```csharp
+    /// <summary>
+    /// 批量取库存基数。
+    ///
+    /// 默认走 165 实时(AiDOP:Stock:Source=Mes):本库 InvMaster 是 165 的一次性快照、
+    /// 不随交易更新(实测 max UpdateTime 停在 2025-12-31),用它判齐套会得出过于乐观的结论。
+    /// 仅配置指定的租户可走 165——本库 InvMaster 含多个租户,其余租户与 165 无对应关系。
+    /// 读 165 失败时回落本库并告警,不让齐套检查整体失败。
+    /// </summary>
+    private async Task<Dictionary<string, decimal>> LoadRawStockMapAsync(
+        List<string> itemNumbers, long tenantId)
+    {
+        if (tenantId <= 0)
+            throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
+        if (itemNumbers.Count == 0)
+            return new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+
+        if (_stockOpt.IsMes && tenantId == _stockOpt.MesTenantId)
+        {
+            try
+            {
+                var mes = await _mesStock.LoadAvailableQtyAsync(
+                    itemNumbers, _stockOpt.MesDomain, _stockOpt.BatchSize);
+                return new Dictionary<string, decimal>(
+                    mes.ToDictionary(kv => kv.Key, kv => RoundQty(kv.Value)),
+                    StringComparer.OrdinalIgnoreCase);
+            }
+            catch (Exception ex)
+            {
+                // 读 165 失败:回落本库快照,结果偏乐观,须在日志里可追溯
+                Log.Warning(ex, "齐套库存读取 165 失败,回落本库 InvMaster 快照(结果可能偏高)");
+            }
+        }
+
+        return await LoadLocalStockMapAsync(itemNumbers, tenantId);
+    }
+
+    /// <summary>本库 InvMaster 批量口径(与 QueryStockQtyAsync 逐条口径一致)。</summary>
+    private async Task<Dictionary<string, decimal>> LoadLocalStockMapAsync(
+        List<string> itemNumbers, long tenantId)
+    {
+        var names = new List<string>(itemNumbers.Count);
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+        for (var i = 0; i < itemNumbers.Count; i++)
+        {
+            names.Add($"@i{i}");
+            pars.Add(new SugarParameter($"@i{i}", itemNumbers[i]));
+        }
+
+        var rows = await _db.Ado.SqlQueryAsync<LocalStockRow>(
+            $"""
+            SELECT ItemNum AS ItemNum,
+                   COALESCE(SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)), 0) AS Qty
+            FROM InvMaster
+            WHERE tenant_id = @TenantId
+              AND ItemNum IN ({string.Join(",", names)})
+            GROUP BY ItemNum
+            """,
+            pars);
+
+        var map = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        foreach (var r in rows)
+        {
+            if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
+            map[r.ItemNum.Trim()] = RoundQty(r.Qty);
+        }
+        return map;
+    }
+
+    private sealed class LocalStockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal Qty { get; set; }
+    }
+```
+
+> `Log.Warning` 是 Furion 的静态日志(项目内已广泛使用)。若该文件没有对应 using 导致编译失败,改为注入 `ILogger` 或直接用 `Console` 之外的既有日志方式,**不要**吞掉异常不留痕。
+
+### 6.4 `QueryStockQtyAsync` 怎么办
+
+**保留不动**。它不再被 `ApplySupplyAsync` 调用,但删除会扩大 diff、也可能被后续需要。若静态分析报"未使用的私有方法",在方法上方加一行注释说明保留原因即可,**不要**改它的 SQL。
+
+---
+
+## 7. 第 5 步:来源痕迹——**本包不做**(已定案)
+
+曾考虑把 `stockSource` 加进 `WorkOrderDispatchService.SingleKittingCheck` 的返回体。**定案为不做**,两条理由:
+
+1. 那段返回体正是 WP-C 要整体重写的地方(WP-C 把缺料从抛异常改为带缺料明细正常返回)。两个包都改同一处只会制造冲突。
+2. 本包的可观测性已经够用:`Mes165StockReader` 每批查询都写 `LogHelper` 明细,165 不可达时写告警日志;`AiDOP:Stock:Source` 的当前取值可直接从 `App.json` 读到。
+
+因此**本包不改 `WorkOrderDispatchService.cs`,不改任何前端文件**。齐套结果的来源标记归 WP-C 的 `check_scope` / `net_scope` 落地。
+
+---
+
+## 8. 验收
+
+### 8.1 编译
+
+```powershell
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+```
+
+### 8.2 核心验收:分歧项被纠正、其余不动
+
+启动后端 → 打开「工单下达」→ 对 `M500000004` 点「齐套检查」→ 执行:
+
+```sql
+SELECT c.item_number, c.needCount, c.sqty, c.lack_qty, e.create_time
+FROM b_bom_child_examine c JOIN b_examine_result e ON e.Id = c.examine_id
+WHERE e.morder_no = 'M500000004' AND e.IsDeleted = 0
+  AND c.item_number IN ('A2C3115','1A03N54','1AB9355','81HC0795','91DC0198','91HC0497')
+ORDER BY c.item_number;
+```
+
+期望:
+
+- `A2C3115` 的 `sqty` = **83543**(改动前 88553)
+- 其余五项分别为 313 / 3260 / 952 / 7 / 35,**一个都不许变**
+
+若 `A2C3115` 仍是 88553:查日志有没有 `齐套库存读取 165 失败`;确认 `mdp_source` 里 `DOPDEMORQ_SQLSERVER` 的 `status=1`;确认工单租户确实是 797403760988229。
+
+### 8.3 实时性验收(端到端)
+
+1. 在 WMS 上对某个 BOM 物料做一笔出库(或让 165 侧发生库存变动)。
+2. **立即**重跑该工单齐套检查(不等任何定时任务)。
+3. `b_bom_child_examine.sqty` 应立刻反映新值。
+
+这一步是 D-A03 的真正验收——冷链方案过不了这一条。
+
+### 8.4 性能验收
+
+```sql
+-- 齐套检查的运行日志,看耗时
+SELECT id, action_code, start_time, end_time,
+       TIMESTAMPDIFF(SECOND, start_time, end_time) AS sec, status
+FROM aidop_action_run_log
+WHERE action_code IN ('S2_KITTING_CHECK_SINGLE', 'S2_KITTING_CHECK_BATCH')
+ORDER BY id DESC LIMIT 10;
+```
+
+单工单齐套应在数秒内完成。批量齐套与「全量重排」也要跑一次,确认没有因跨库查询变慢到超时(前端超时是 300 秒)。若明显变慢,先确认是不是 `BatchSize` 太小导致分块过多。
+
+### 8.5 降级验收
+
+把 `App.json` 里 `AiDOP:Stock:Source` 改成 `"Local"`,重启,重跑齐套:`A2C3115` 应回到 88553。确认开关有效后**改回 `"Mes"`**。
+
+### 8.6 多租户安全验收
+
+```sql
+-- 确认第二个租户的库存没被 165 值污染
+SELECT tenant_id, COUNT(*) FROM InvMaster GROUP BY tenant_id;
+-- 期望:797403760988229 → 16022;824585161322565 → 2346(本包不写 InvMaster,行数必须不变)
+```
+
+本包**不写任何库存表**,所以两个租户的 `InvMaster` 行数与数值都应与改动前完全一致。
+
+---
+
+## 9. 与 WP-C 的并行冲突
+
+两包都改 `MaterialRequirementCalculator.cs`,但区段不同:
+
+| 包 | 改动区段 |
+|---|---|
+| WP-B(本包) | 构造函数、`ApplySupplyAsync` **循环入口前**的预取、`QueryStockQtyAsync` 之前新增的私有方法 |
+| WP-C | `ApplySupplyAsync` **开头的 `bangId > 0` 占用查询块** 与 **末尾的占用写入块** |
+
+Git 通常能自动合并。约定:**先完成的先提,后完成的 rebase**。rebase 后必须重跑对方的核心验收(WP-B 看 `A2C3115=83543`,WP-C 看两条路径口径一致)。
+
+---
+
+## 10. 风险与回滚
+
+| 风险 | 影响 | 处置 |
+|---|---|---|
+| 165 不可达 | 齐套结论回落到快照值(偏乐观) | 已有 try/catch + 告警日志。运维上把该日志纳入巡检 |
+| 排产全量场景跨库查询量大 | 排产变慢 | 已批量化(每 500 物料一次查询,4775 个物料全量也只需 10 次)。若仍慢,把预取上移到排产入口做全局一次预取——**但那属于另一个任务,不在本包** |
+| 165 与本库物料编码有大小写/空格差异 | 匹配不上 → 库存算 0 → 虚假缺料 | Reader 与 map 都用 `StringComparer.OrdinalIgnoreCase` 且 `Trim()`。验收 8.2 的六项若有任何一项变成 0,就是这个问题 |
+| 第二个租户误走 165 | 读到别人的库存 | `MesTenantId` 闸门 + 验收 8.6 |
+
+**回滚**:把 `App.json` 的 `Source` 改成 `"Local"` 即可,不需回代码。代码回滚则还原 4 个文件(新增 2 个直接删除)。
+
+---
+
+## 11. 明确不做
+
+- 不改 165 的表、存储过程、数据;不向 165 写入任何内容。
+- 不建 `mdp_std_inventory`、不启用 `S5_INV_MASTER_SQLSERVER` 实体、不建库存同步定时任务(冷链方案本包不做;如需库存看板/报表用实时数据,另开任务)。
+- 不删、不刷本库 `InvMaster`。
+- 不改齐套口径(库位范围、数量列、`IsActive` 过滤、在途 `PurOrdDetail` 的算法)。
+- 不改占用扣减逻辑(`ic_item_stockoccupy` / `srm_po_occupy`)——那是 WP-C。
+- 不改前端。
+
+---
+
+## 12. 版本号(强制)
+
+本包改动全部在**后端代码 + 后端配置**:同一提交内把
+`server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>`
+**三处同号 patch +1**(当前 `1.0.327`,按仓库实际值末位 +1)。本包不含前端改动,**不要**动 `Web/package.json`。

+ 993 - 0
doc/plan/UAT缺陷整治任务书/WP-C-齐套口径统一.md

@@ -0,0 +1,993 @@
+# WP-C|齐套口径统一与结果不再互相覆盖(D-A02 + O-A01)
+
+> **状态:定案版(可直接执行)**。所有决策已锁,无需再确认。
+> 执行者:熟悉 C# / SqlSugar / Vue3 的模型或工程师。预计 1 天。
+> 前置:无。与 WP-B 都会改 `MaterialRequirementCalculator.cs`,但改的是**不同区段**(见 §10)。
+
+---
+
+## 0. 本包定案
+
+| 编号 | 决策项 | 定案 | 依据 / 推翻条件 |
+|---|---|---|---|
+| C-D1 | 口径统一方向 | 全部路径统一为**净口径**(扣除同一排程版本内其他工单已占用的库存) | 毛口径会让每个工单都以为自己能独占全部库存,多工单同时下达必然超发。净口径是唯一能自洽的口径。推翻条件:业务明确要求"单工单检查只看仓库总量、不看别人的预留" |
+| C-D2 | 自占用处理 | 扣占用时**排除本工单自己的占用行**(`morder_mo <> 本工单`) | 不排除的话,一个工单第二次点齐套检查会把自己第一次的占用当成别人的,缺料量凭空翻倍 |
+| C-D3 | 单工单检查是否写占用 | **只读不写**。只有排产(`bangId` 由排产运行产生)才写 `ic_item_stockoccupy` / `srm_po_occupy` | 手动点齐套检查是"查询"语义。若写占用,用户多点几次就会把库存全占掉,且没有任何清理时机 |
+| C-D4 | 结果失效范围 | `InvalidatePreviousAsync` 以 `(tenant_id, morder_no)` 为主键条件;`sentry_id` 只在 `> 0` 时**追加**;SELECT 与 UPDATE 条件必须一致 | 现状 SELECT 用 `sentry_id OR morder_no`、UPDATE 只用 `sentry_id`,两者不一致;且 `sentry_id = 0`(工单无关联订单行)会命中全部同类行,造成跨工单批量失效 |
+| C-D5 | 结果可追溯 | `b_examine_result` 写入 `bangid`(列已存在但从未写过)+ 新增 `check_scope` 列记录触发来源 | 不记来源就永远说不清"这条结论是排产算的还是手点的",D-A02 这类问题会反复出现 |
+| C-D6 | 前端缺料提示 | 缺料是**正常业务结论**,用 `ElMessage.warning` + 刷新列表;不再 `ElMessage.error` | 现状后端对缺料抛异常、前端弹红色报错且**不刷新列表**,用户看不到刚算出来的缺料状态 |
+| C-D7 | 下达是否因缺料阻断 | **不阻断**。下达继续沿用现有行为(记录 `materialSituation` 后继续建领料) | 改变下达阻断逻辑属业务规则变更,超出缺陷整治范围 |
+
+---
+
+## 1. 目标
+
+同一个工单,无论从哪个入口触发齐套检查,都得到**同一个结论**;后一次检查不再破坏无关工单的历史结果;缺料时用户能立刻在列表上看到。
+
+---
+
+## 2. 缺陷事实(已核实,附行号)
+
+### 2.1 两条路径口径不同
+
+`WorkOrderKittingCheckService.CheckSingleAsync` 的 `bangId` 参数决定口径,而三个调用方传的值不同:
+
+| 调用方 | 位置 | 传入 `bangId` | 实际口径 |
+|---|---|---|---|
+| 排产 | `ProductionScheduleGenerationService.cs:250` `CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId)` | **> 0** | **净**:扣同批次占用 + **写**占用 |
+| 工单下达 | `WorkOrderDispatchService.cs:277` `CheckSingleAsync(tid, wo, account)` | 0(默认值) | **毛**:不扣占用、不写占用 |
+| 手动单工单检查 | `WorkOrderDispatchService.cs:396` `CheckSingleAsync(tid, wo, account)` | 0 | **毛** |
+| 批量检查 | `WorkOrderKittingCheckService.cs:43` `CheckSingleAsync(tenantId, wo, account)` | 0 | **毛** |
+
+口径分叉点在 `MaterialRequirementCalculator.ApplySupplyAsync`:
+
+```264:289:server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs
+        if (bangId > 0)
+        {
+            var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                """
+                SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
+                FROM ic_item_stockoccupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                GROUP BY icitem_number
+                """,
+```
+
+`bangId = 0` 时 `occupiedStockMap` 为空 → 不扣任何占用 → 缺料量偏小 → 结论偏乐观。
+
+### 2.2 两条路径写同一张表,后写覆盖前写
+
+两条路径最终都调用 `ResourceCheckResultWriter.WriteAsync`(`WorkOrderKittingCheckService.cs:96`),而 `WriteAsync` 第一件事就是把历史结果失效:
+
+```162:198:server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs
+    private async Task InvalidatePreviousAsync(long entryId, long tenantId, string morderNo, DateTime now)
+    {
+        var oldIds = await _db.Ado.SqlQueryAsync<long>(
+            """
+            SELECT Id FROM b_examine_result
+            WHERE tenant_id = @TenantId AND IsDeleted = 0
+              AND (sentry_id = @EntryId OR morder_no = @MorderNo)
+            """,
+```
+
+后面的 UPDATE 却只按 `sentry_id`:
+
+```189:197:server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE b_examine_result
+            SET IsDeleted = 1, update_time = @Now
+            WHERE sentry_id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
+            """,
+```
+
+后果:
+
+1. **口径覆盖**:排产算出的净口径结果,被用户手点一次齐套检查产生的毛口径结果覆盖(或反之)。用户看到的 `mes_morder.MaterialSituation` 取决于"最后点了哪个按钮"。实测 `M500000004` 在 `b_examine_result` 里有约 20 条同 `sentry_id` 的历史记录,仅最新一条 `IsDeleted=0`。
+2. **潜在跨工单误失效**:当工单没有关联订单明细行时(代码里的 `2b. 无关联订单明细行(BusinessID=0)` 分支),`entry.Id = 0`,于是 `sentry_id = 0` 会命中**所有**同类历史结果,把别的工单的结论一起标删。
+   目前尚未触发——实测 22 个工单的 `BusinessID` 全部 > 0、`b_examine_result` 里 `sentry_id = 0` 的行数为 0——但这是**待引爆的雷**:只要出现一个不挂订单的工单(补单、返工单、手工单)就会炸。
+
+### 2.3 缺料时前端体验错误
+
+后端把"缺料"当异常抛:
+
+```396:401:server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs
+            var result = await _kittingCheck.CheckSingleAsync(tid, wo, account);
+            if (!result.IsKitted)
+            {
+                await _runLog.FailedAsync(logId, $"缺料行 {result.ShortageLineCount}", result);
+                throw Oops.Oh($"工单 {workord} 齐套检查未通过(缺料行 {result.ShortageLineCount})");
+```
+
+前端于是走进 `catch`,弹红色错误且**不刷新列表**:
+
+```268:285:Web/src/views/aidop/business/workOrderDispatchList.vue
+async function onSingleKitCheck(row: WorkOrderDispatchRow) {
+	const domain = domainParamFromCurrentTenant();
+	if (!domain || !row.workOrd) {
+		ElMessage.error('当前用户无租户信息或工单号为空');
+		return;
+	}
+	const account = userInfo.userInfos?.account ?? '';
+	singleKittingWo.value = row.workOrd;
+	try {
+		await kittingCheckSingle(row.workOrd, domain, account);
+		ElMessage.success(`工单 ${row.workOrd} 齐套检查已执行`);
+		await loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '单工单齐套检查失败');
+	} finally {
+		singleKittingWo.value = null;
+	}
+}
+```
+
+用户观感:检查"失败了",而且列表上的物料状况还是旧值。实际上检查成功了,只是结论是缺料。
+
+---
+
+## 3. 第 1 步:事实复核(必须先做)
+
+```sql
+-- ① 同一工单的历史齐套结果条数与失效情况(期望:多条,仅 1 条 IsDeleted=0)
+SELECT Id, sentry_id, morder_no, IsDeleted, bangid, create_time
+FROM b_examine_result WHERE morder_no = 'M500000004' ORDER BY create_time DESC LIMIT 25;
+
+-- ② bangid 是否从未写过(期望:全 NULL 或全 0)
+SELECT COUNT(*) AS total, SUM(CASE WHEN IFNULL(bangid,0) > 0 THEN 1 ELSE 0 END) AS with_bang
+FROM b_examine_result;
+
+-- ③ sentry_id=0 的雷是否已引爆(期望:0)
+SELECT COUNT(*) AS zero_sentry FROM b_examine_result WHERE IFNULL(sentry_id, 0) = 0;
+
+-- ④ 当前是否存在不挂订单的工单(期望:0;若 > 0,2.2 的雷已具备引爆条件,优先级提高)
+SELECT COUNT(*) AS wo_without_order FROM WorkOrdMaster WHERE IFNULL(BusinessID, 0) = 0;
+
+-- ⑤ 当前有效排程版本(后续 bangId 复用它;期望:有一条 SUCCESS 记录)
+SELECT id, action_code, status, start_time FROM aidop_action_run_log
+WHERE action_code = 'S2_SCHEDULE_GENERATE' AND status = 'SUCCESS'
+ORDER BY start_time DESC, id DESC LIMIT 3;
+```
+
+若 ⑤ 无记录(从未成功排产过),本包仍可执行:`ResolveCurrentScheduleBangIdAsync` 返回 null 时口径退化为毛口径,与改动前一致,不会更差。
+
+---
+
+## 4. 第 2 步:数据库脚本
+
+新建 `server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.sql`。
+
+> **迁移脚本的四条硬约束**(来自 `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`,违反会导致脚本不执行或后端启动失败):
+> 1. **行尾必须 LF**。CRLF 会改变 SHA256,与 `sys_db_migration_log` 已记录的 hash 不一致时直接抛异常阻止启动。
+> 2. **脚本号必须 ≤ `.csproj` 的 `<Version>`**(`pending = scripts.Where(s => s.ParsedVersion <= currentVersion)`)。当前后端版本 **1.0.327**,已占用脚本号最大为 **1.0.323**,WP-D 取 `1.0.324`,本包取 `1.0.325`。若已被占用,取下一个未占用且 ≤ 当前版本的号并同步更新 README。
+> 3. 脚本内**不得出现 `DELIMITER` 字样**(含注释里),执行器直接拒绝。
+> 4. 已成功执行过的脚本**不可再改内容**(hash 校验),需要修正就新建更高版本号的脚本。
+>
+> 本脚本使用 `SET @` 与 `PREPARE`,执行器会识别并**整段同连接执行**(不按 `;` 拆分),与 `1.0.321.sql` 等既有脚本同一写法,可放心沿用。
+
+```sql
+-- 1.0.325.sql  WP-C 齐套口径统一:为齐套结果头补来源标记
+--   D-A02:排产(净口径)与手动检查(毛口径)写同一张 b_examine_result 且互相覆盖,
+--          事后无法判断某条结论出自哪条路径。bangid 列早已存在但从未写入。
+--   幂等:经 information_schema 判列是否存在,可重复执行。
+--   行尾必须 LF(CRLF 会导致 SHA256 与 sys_db_migration_log 不一致并阻止启动)。
+SET NAMES utf8mb4;
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'check_scope'),
+  'SELECT 1',
+  'ALTER TABLE b_examine_result ADD COLUMN check_scope VARCHAR(20) NULL COMMENT ''触发来源:SCHEDULE=排产内嵌/RELEASE=工单下达/SINGLE=手动单工单/BATCH=批量/REVIEW=订单评审'' AFTER bangid'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'net_scope'),
+  'SELECT 1',
+  'ALTER TABLE b_examine_result ADD COLUMN net_scope TINYINT(1) NULL DEFAULT 0 COMMENT ''1=净口径(已扣同版本占用);0=毛口径'' AFTER check_scope'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+```
+
+同时新建**校验脚本** `server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.verify.sql`(同样 LF)。执行器在主脚本成功后逐条跑它,任一条返回 0/NULL 即写入 `verify_status=Failed`:
+
+```sql
+SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'check_scope';
+SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'net_scope';
+```
+
+---
+
+## 5. 第 3 步:`MaterialRequirementCalculator` 支持"只读净口径"
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs`
+
+### 5.1 `BuildLinesAsync` 增加两个参数
+
+**定位锚点**:
+
+```csharp
+    public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        List<string> warnings,
+        long bangId = 0,
+        string? workOrd = null,
+        long morderId = 0)
+    {
+```
+
+**替换为**:
+
+```csharp
+    /// <param name="writeOccupy">
+    /// 是否登记库存/在途占用。只有排产才写(占用有明确的版本与清理时机);
+    /// 手动齐套检查是查询语义,多点几次不应把库存占光。
+    /// </param>
+    /// <param name="excludeOccupyWorkOrd">
+    /// 扣减占用时要排除的工单号(通常是本工单)。不排除会把自己上一次的占用当成别人的,
+    /// 导致同一工单反复检查时缺料量不断翻倍。
+    /// </param>
+    public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        List<string> warnings,
+        long bangId = 0,
+        string? workOrd = null,
+        long morderId = 0,
+        bool writeOccupy = true,
+        string? excludeOccupyWorkOrd = null)
+    {
+```
+
+### 5.2 三处内部调用透传
+
+文件内有 **3 处**完全相同的调用(约 55 / 63 / 74 行):
+
+```csharp
+            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId);
+```
+
+**全部替换为**(3 处都改,可用编辑器全量替换):
+
+```csharp
+            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
+```
+
+> 第 74 行那处缩进是 8 空格,替换时保留各自原有缩进。
+
+### 5.3 `ApplySupplyAsync` 签名与占用查询
+
+**定位锚点**:
+
+```csharp
+    private async Task ApplySupplyAsync(
+        List<ResourceCheckBomLine> lines,
+        long tenantId,
+        long bangId = 0,
+        long entryId = 0,
+        string? workOrd = null,
+        long morderId = 0)
+    {
+```
+
+**替换为**:
+
+```csharp
+    private async Task ApplySupplyAsync(
+        List<ResourceCheckBomLine> lines,
+        long tenantId,
+        long bangId = 0,
+        long entryId = 0,
+        string? workOrd = null,
+        long morderId = 0,
+        bool writeOccupy = true,
+        string? excludeOccupyWorkOrd = null)
+    {
+```
+
+**定位锚点**(占用查询块):
+
+```csharp
+        if (bangId > 0)
+        {
+            var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                """
+                SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
+                FROM ic_item_stockoccupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                GROUP BY icitem_number
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", bangId));
+            foreach (var r in occupiedRows)
+                occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
+
+            var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                """
+                SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
+                FROM srm_po_occupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                GROUP BY ItemNumber
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@BangId", bangId));
+            foreach (var r in occupiedTransitRows)
+                occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
+        }
+```
+
+**替换为**:
+
+```csharp
+        if (bangId > 0)
+        {
+            // 排除指定工单(通常是本工单)自己的占用,否则同一工单重复检查会把自己的占用当别人的
+            var excludeWo = (excludeOccupyWorkOrd ?? "").Trim();
+            var excludeClause = excludeWo.Length == 0 ? "" : " AND IFNULL(morder_mo, '') <> @ExcludeWo";
+
+            var occupyPars = new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BangId", bangId)
+            };
+            if (excludeWo.Length > 0)
+                occupyPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
+
+            var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                $"""
+                SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
+                FROM ic_item_stockoccupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
+                GROUP BY icitem_number
+                """,
+                occupyPars);
+            foreach (var r in occupiedRows)
+                occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
+
+            var transitPars = new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BangId", bangId)
+            };
+            if (excludeWo.Length > 0)
+                transitPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
+
+            var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
+                $"""
+                SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
+                FROM srm_po_occupy
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
+                GROUP BY ItemNumber
+                """,
+                transitPars);
+            foreach (var r in occupiedTransitRows)
+                occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
+        }
+```
+
+> `srm_po_occupy` 的工单列名也是 `morder_mo`(见同文件末尾的 INSERT)。若实际列名不同,按表结构调整该表的 `excludeClause`。
+
+### 5.4 占用写入加开关
+
+**定位锚点**(占用写入块的入口):
+
+```csharp
+        // 写入占用记录(库存占用和在途占用)
+        if (bangId > 0)
+        {
+            foreach (var line in lines)
+```
+
+**替换为**:
+
+```csharp
+        // 写入占用记录(库存占用和在途占用)。只有排产写;手动齐套检查是查询语义,只读不写
+        if (bangId > 0 && writeOccupy)
+        {
+            foreach (var line in lines)
+```
+
+块内其余代码**不动**。
+
+---
+
+## 6. 第 4 步:`WorkOrderKittingCheckService` 统一口径
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderKittingCheckService.cs`
+
+### 6.1 `CheckSingleAsync` 增加来源参数并解析 `bangId`
+
+**定位锚点**:
+
+```csharp
+    public async Task<SingleKittingCheckResult> CheckSingleAsync(long tenantId, string workOrd, string account, long bangId = 0)
+    {
+        var wo = workOrd.Trim();
+```
+
+**替换为**:
+
+```csharp
+    /// <param name="bangId">
+    /// 排程版本。&gt; 0 表示由排产驱动(净口径 + 写占用);
+    /// = 0 表示手动/下达触发,此时自动复用当前有效排程版本,保持口径一致但只读不写。
+    /// </param>
+    /// <param name="checkScope">触发来源,写入 b_examine_result.check_scope 供事后追溯。</param>
+    public async Task<SingleKittingCheckResult> CheckSingleAsync(
+        long tenantId, string workOrd, string account, long bangId = 0, string checkScope = "SINGLE")
+    {
+        var wo = workOrd.Trim();
+
+        // 口径统一(C-D1):手动/下达触发时也按净口径算——复用当前有效排程版本的占用账,
+        // 否则每个工单都以为自己能独占全部库存,多工单同时下达必然超发。
+        var isScheduleDriven = bangId > 0;
+        if (!isScheduleDriven)
+            bangId = await ResolveCurrentScheduleBangIdAsync(tenantId) ?? 0;
+```
+
+### 6.2 传递新参数给计算器
+
+**定位锚点**:
+
+```csharp
+        // 3. 重新执行资源检查(BOM 展开 + 库存/在途缺口计算)
+        var warnings = new List<string>();
+        var lines = await _calculator.BuildLinesAsync(order, entry, warnings, bangId, wo, wm.MorderId ?? 0);
+
+        // 4. 将新结果写入 b_examine_result / b_bom_child_examine 并更新 mes_morder
+        var checkResult = await _writer.WriteAsync(order, entry, wo, wm.MorderId, lines, account, DateTime.Now);
+```
+
+**替换为**:
+
+```csharp
+        // 3. 重新执行资源检查(BOM 展开 + 库存/在途缺口计算)
+        //    非排产触发:只读不写占用(C-D3),并排除本工单自身占用(C-D2)
+        var warnings = new List<string>();
+        var lines = await _calculator.BuildLinesAsync(
+            order, entry, warnings, bangId, wo, wm.MorderId ?? 0,
+            writeOccupy: isScheduleDriven,
+            excludeOccupyWorkOrd: wo);
+
+        // 4. 将新结果写入 b_examine_result / b_bom_child_examine 并更新 mes_morder
+        var checkResult = await _writer.WriteAsync(
+            order, entry, wo, wm.MorderId, lines, account, DateTime.Now,
+            bangId: bangId, checkScope: checkScope);
+```
+
+> 注意:排产路径也传了 `excludeOccupyWorkOrd: wo`。这是正确的——排产在算某工单前会先删掉该工单在本版本下的旧占用(`ProductionScheduleGenerationService` 单工单重排分支就是这么做的),排除自身不改变排产语义,却能防住"同版本内重复计算同一工单"的叠加。
+
+### 6.3 新增排程版本解析方法
+
+**在 `LoadWorkOrdMasterAsync` 方法之前插入**:
+
+```csharp
+    /// <summary>
+    /// 取当前有效排程版本 id(= 最近一次成功排产的 aidop_action_run_log.id,与 bang_id 同源)。
+    /// 与 ProductionScheduleGenerationService.ResolveCurrentScheduleBangIdAsync 同逻辑;
+    /// 无成功排产记录时返回 null,口径退化为毛口径(与改动前行为一致,不会更差)。
+    /// </summary>
+    private async Task<long?> ResolveCurrentScheduleBangIdAsync(long tenantId)
+    {
+        var id = await _db.Ado.GetLongAsync(
+            """
+            SELECT id FROM aidop_action_run_log
+            WHERE tenant_id = @TenantId AND action_code = @ActionCode AND status = 'SUCCESS'
+            ORDER BY start_time DESC, id DESC
+            LIMIT 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@ActionCode", "S2_SCHEDULE_GENERATE")
+            });
+        return id > 0 ? id : null;
+    }
+```
+
+### 6.4 批量检查传来源
+
+**定位锚点**(`CheckTenantWorkOrdersAsync` 内):
+
+```csharp
+            var one = await CheckSingleAsync(tenantId, wo, account);
+```
+
+**替换为**:
+
+```csharp
+            var one = await CheckSingleAsync(tenantId, wo, account, bangId: 0, checkScope: "BATCH");
+```
+
+---
+
+## 7. 第 5 步:`ResourceCheckResultWriter` 修失效范围并记来源
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs`
+
+### 7.1 `WriteAsync` 增加两个参数
+
+**定位锚点**:
+
+```csharp
+    public async Task<OrderResourceCheckResult> WriteAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        string workOrd,
+        long? morderId,
+        IReadOnlyList<ResourceCheckBomLine> lines,
+        string account,
+        DateTime now)
+    {
+        await InvalidatePreviousAsync(entry.Id, entry.TenantId, workOrd, now);
+```
+
+**替换为**:
+
+```csharp
+    public async Task<OrderResourceCheckResult> WriteAsync(
+        OrderWorkOrderGenerationService.OrderHeader order,
+        OrderWorkOrderGenerationService.OrderEntryLine entry,
+        string workOrd,
+        long? morderId,
+        IReadOnlyList<ResourceCheckBomLine> lines,
+        string account,
+        DateTime now,
+        long bangId = 0,
+        string checkScope = "REVIEW")
+    {
+        await InvalidatePreviousAsync(entry.Id, entry.TenantId, workOrd, now);
+```
+
+### 7.2 INSERT 写入 `bangid` 与 `check_scope`
+
+**定位锚点**(INSERT 的列清单与 VALUES):
+
+```csharp
+            INSERT INTO b_examine_result (
+                Id, sorderid, bill_no, sentry_id, entry_seq, morder_id, morder_no, bom_number,
+                need_qty, need_time, earliest_times, latest_times, order_statr_time, kitting_times,
+                sys_capacity_date, sys_material_date,
+                create_by_name, create_time, update_by_name, update_time,
+                tenant_id, factory_id, company_id, IsDeleted
+            ) VALUES (
+                @Id, @SorderId, @BillNo, @SentryId, @EntrySeq, @MorderId, @MorderNo, @BomNumber,
+                @NeedQty, @NeedTime, @NeedTime, @NeedTime, @NeedTime, @KittingTime,
+                @NeedTime, @KittingTime,
+                @User, @Now, @User, @Now,
+                @TenantId, @FactoryId, @CompanyId, 0
+            )
+```
+
+**替换为**:
+
+```csharp
+            INSERT INTO b_examine_result (
+                Id, sorderid, bill_no, sentry_id, entry_seq, morder_id, morder_no, bom_number,
+                need_qty, need_time, earliest_times, latest_times, order_statr_time, kitting_times,
+                sys_capacity_date, sys_material_date,
+                bangid, check_scope, net_scope,
+                create_by_name, create_time, update_by_name, update_time,
+                tenant_id, factory_id, company_id, IsDeleted
+            ) VALUES (
+                @Id, @SorderId, @BillNo, @SentryId, @EntrySeq, @MorderId, @MorderNo, @BomNumber,
+                @NeedQty, @NeedTime, @NeedTime, @NeedTime, @NeedTime, @KittingTime,
+                @NeedTime, @KittingTime,
+                @BangId, @CheckScope, @NetScope,
+                @User, @Now, @User, @Now,
+                @TenantId, @FactoryId, @CompanyId, 0
+            )
+```
+
+并在参数列表里(`new SugarParameter("@KittingTime", kittingTime ?? needTime),` 之后)**插入**:
+
+```csharp
+            new SugarParameter("@BangId", bangId > 0 ? bangId : (object)DBNull.Value),
+            new SugarParameter("@CheckScope", string.IsNullOrWhiteSpace(checkScope) ? "REVIEW" : checkScope.Trim()),
+            new SugarParameter("@NetScope", bangId > 0 ? 1 : 0),
+```
+
+### 7.3 修 `InvalidatePreviousAsync`(本包最关键的一处)
+
+**定位锚点**(整个方法):
+
+```csharp
+    private async Task InvalidatePreviousAsync(long entryId, long tenantId, string morderNo, DateTime now)
+    {
+        var oldIds = await _db.Ado.SqlQueryAsync<long>(
+            """
+            SELECT Id FROM b_examine_result
+            WHERE tenant_id = @TenantId AND IsDeleted = 0
+              AND (sentry_id = @EntryId OR morder_no = @MorderNo)
+            """,
+            new SugarParameter("@EntryId", entryId),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@MorderNo", morderNo));
+
+        if (oldIds.Count == 0)
+            return;
+
+        foreach (var examineId in oldIds)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE b_bom_child_examine
+                SET is_use = 0, update_time = @Now
+                WHERE examine_id = @ExamineId
+                """,
+                new SugarParameter("@ExamineId", examineId),
+                new SugarParameter("@Now", now));
+        }
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE b_examine_result
+            SET IsDeleted = 1, update_time = @Now
+            WHERE sentry_id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
+            """,
+            new SugarParameter("@EntryId", entryId),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@Now", now));
+    }
+```
+
+**替换为**:
+
+```csharp
+    /// <summary>
+    /// 让同一工单(或同一订单明细行)的历史齐套结果失效。
+    ///
+    /// 两个要点:
+    /// 1) 以 morder_no 为主条件。sentry_id 只在 &gt; 0 时追加——工单不挂订单明细行时 entry.Id = 0,
+    ///    若按 sentry_id = 0 失效会命中所有同类历史结果,把别的工单的结论一起标删。
+    /// 2) SELECT 与 UPDATE 必须用同一套条件,否则会出现"子表明细已失效、头还是有效"的半失效状态。
+    /// </summary>
+    private async Task InvalidatePreviousAsync(long entryId, long tenantId, string morderNo, DateTime now)
+    {
+        var mo = (morderNo ?? "").Trim();
+        if (mo.Length == 0 && entryId <= 0)
+            return;
+
+        var conds = new List<string>();
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+        if (mo.Length > 0)
+        {
+            conds.Add("morder_no = @MorderNo");
+            pars.Add(new SugarParameter("@MorderNo", mo));
+        }
+        if (entryId > 0)
+        {
+            conds.Add("sentry_id = @EntryId");
+            pars.Add(new SugarParameter("@EntryId", entryId));
+        }
+
+        var scope = $"tenant_id = @TenantId AND IsDeleted = 0 AND ({string.Join(" OR ", conds)})";
+
+        var oldIds = await _db.Ado.SqlQueryAsync<long>(
+            $"SELECT Id FROM b_examine_result WHERE {scope}", pars);
+
+        if (oldIds.Count == 0)
+            return;
+
+        foreach (var examineId in oldIds)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE b_bom_child_examine
+                SET is_use = 0, update_time = @Now
+                WHERE examine_id = @ExamineId
+                """,
+                new SugarParameter("@ExamineId", examineId),
+                new SugarParameter("@Now", now));
+        }
+
+        var updPars = new List<SugarParameter>(pars) { new("@Now", now) };
+        await _db.Ado.ExecuteCommandAsync(
+            $"UPDATE b_examine_result SET IsDeleted = 1, update_time = @Now WHERE {scope}",
+            updPars);
+    }
+```
+
+---
+
+## 8. 第 6 步:下达路径与手动检查传来源
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs`
+
+### 8.1 下达
+
+**定位锚点**:
+
+```csharp
+            var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account);
+```
+
+**替换为**:
+
+```csharp
+            var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "RELEASE");
+```
+
+(下达仍不因缺料阻断 —— C-D7。)
+
+### 8.2 手动单工单检查:缺料不再抛异常
+
+**定位锚点**:
+
+```csharp
+            var result = await _kittingCheck.CheckSingleAsync(tid, wo, account);
+            if (!result.IsKitted)
+            {
+                await _runLog.FailedAsync(logId, $"缺料行 {result.ShortageLineCount}", result);
+                throw Oops.Oh($"工单 {workord} 齐套检查未通过(缺料行 {result.ShortageLineCount})");
+            }
+
+            await _runLog.SuccessAsync(logId, "单工单齐套检查通过", result);
+            return new { message = "ok", materialSituation = result.MaterialSituation };
+        }
+        catch (Exception ex)
+        {
+            if (ex.Message.Contains("齐套检查未通过", StringComparison.Ordinal))
+                throw;
+            await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
+            throw;
+        }
+```
+
+**替换为**:
+
+```csharp
+            var result = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "SINGLE");
+
+            // 缺料是正常业务结论,不是接口失败:照常返回,由前端以告警形式呈现并刷新列表
+            await _runLog.SuccessAsync(
+                logId,
+                result.IsKitted ? "单工单齐套检查通过" : $"单工单齐套检查完成(缺料行 {result.ShortageLineCount})",
+                result);
+            return new
+            {
+                message = "ok",
+                materialSituation = result.MaterialSituation,
+                isKitted = result.IsKitted,
+                shortageLineCount = result.ShortageLineCount
+            };
+        }
+        catch (Exception ex)
+        {
+            await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
+            throw;
+        }
+```
+
+---
+
+## 9. 第 7 步:前端
+
+### 9.1 API 返回结果
+
+**文件**:`Web/src/views/aidop/api/workOrderDispatch.ts`
+
+**定位锚点**:
+
+```typescript
+/** 单工单齐套检查(替代旧 WorkOrdKittingCheckByNo) */
+export async function kittingCheckSingle(workOrd: string, domain: string, userAccount: string): Promise<void> {
+	await service.post('/api/WorkOrder/dispatch/work-order-kitting-check', null, {
+		params: { workord: workOrd, domain, userAccount },
+		timeout: 300000,
+	});
+}
+```
+
+**替换为**:
+
+```typescript
+export interface KittingCheckSingleResult {
+	materialSituation?: string | null;
+	isKitted?: boolean;
+	shortageLineCount?: number;
+}
+
+/** 单工单齐套检查(替代旧 WorkOrdKittingCheckByNo);缺料属正常结论,由调用方按 isKitted 呈现 */
+export async function kittingCheckSingle(
+	workOrd: string,
+	domain: string,
+	userAccount: string
+): Promise<KittingCheckSingleResult> {
+	const res = await service.post('/api/WorkOrder/dispatch/work-order-kitting-check', null, {
+		params: { workord: workOrd, domain, userAccount },
+		timeout: 300000,
+	});
+	const body = res.data as (KittingCheckSingleResult & { result?: KittingCheckSingleResult }) | undefined;
+	return body?.result ?? body ?? {};
+}
+```
+
+### 9.2 缺料改为告警并刷新列表
+
+**文件**:`Web/src/views/aidop/business/workOrderDispatchList.vue`
+
+**定位锚点**:
+
+```typescript
+	const account = userInfo.userInfos?.account ?? '';
+	singleKittingWo.value = row.workOrd;
+	try {
+		await kittingCheckSingle(row.workOrd, domain, account);
+		ElMessage.success(`工单 ${row.workOrd} 齐套检查已执行`);
+		await loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '单工单齐套检查失败');
+	} finally {
+		singleKittingWo.value = null;
+	}
+```
+
+**替换为**:
+
+```typescript
+	const account = userInfo.userInfos?.account ?? '';
+	singleKittingWo.value = row.workOrd;
+	try {
+		const r = await kittingCheckSingle(row.workOrd, domain, account);
+		if (r.isKitted === false) {
+			ElMessage.warning(`工单 ${row.workOrd} 缺料(缺料行 ${r.shortageLineCount ?? 0})`);
+		} else {
+			ElMessage.success(`工单 ${row.workOrd} 齐套检查已执行`);
+		}
+		await loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '单工单齐套检查失败');
+		await loadList();
+	} finally {
+		singleKittingWo.value = null;
+	}
+```
+
+> `catch` 分支也加 `loadList()`:即使接口报错,物料状况可能已被后端更新,列表必须与库里一致。
+
+### 9.3 不要顺手做的事
+
+- **不要**改「批量齐套检查」按钮的提示逻辑(后端本来就不抛缺料异常)。
+- **不要**改表格列定义、`materialSituation` 的着色规则。
+- **不要**给缺料行加弹窗/抽屉展示明细——那是新功能。
+
+---
+
+## 10. 与 WP-B 的并行冲突
+
+两包都改 `MaterialRequirementCalculator.cs`:
+
+| 包 | 改动区段 |
+|---|---|
+| WP-B | 构造函数、`ApplySupplyAsync` **循环入口前**的库存预取、新增私有取数方法 |
+| WP-C(本包) | `BuildLinesAsync` 签名与 3 处透传、`ApplySupplyAsync` **签名**、**开头的占用查询块**、**末尾的占用写入块入口** |
+
+`ApplySupplyAsync` 的签名行两包都会碰(WP-B 不改签名,只改方法体,所以实际只有本包改签名)。约定:**先完成的先提,后完成的 rebase**,rebase 后各自重跑核心验收。
+
+---
+
+## 11. 验收
+
+### 11.1 编译
+
+```powershell
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+cd Web; pnpm build
+```
+
+### 11.2 口径一致(核心验收)
+
+1. 先「全量重排」跑一次,记下产生的排程版本:
+   ```sql
+   SELECT id FROM aidop_action_run_log
+   WHERE action_code = 'S2_SCHEDULE_GENERATE' AND status = 'SUCCESS'
+   ORDER BY id DESC LIMIT 1;
+   ```
+2. 记录排产写出的缺料明细:
+   ```sql
+   SELECT c.item_number, c.needCount, c.sqty, c.lack_qty
+   FROM b_bom_child_examine c JOIN b_examine_result e ON e.Id = c.examine_id
+   WHERE e.morder_no = 'M500000004' AND e.IsDeleted = 0 AND c.is_use = 1
+   ORDER BY c.item_number;
+   ```
+3. 对同一工单点「齐套检查」,再跑一次上面的 SQL。
+
+**期望:两次的 `lack_qty` 逐行完全一致。** 这是 D-A02 是否修好的唯一判据。
+
+### 11.3 来源可追溯
+
+```sql
+-- 期望:排产那条 check_scope='SCHEDULE'、net_scope=1、bangid=排程版本;
+--       手点那条 check_scope='SINGLE'、net_scope=1、bangid=同一个排程版本
+SELECT Id, morder_no, bangid, check_scope, net_scope, IsDeleted, create_time
+FROM b_examine_result WHERE morder_no = 'M500000004'
+ORDER BY create_time DESC LIMIT 5;
+```
+
+### 11.4 重复检查不叠加缺料(C-D2)
+
+对同一工单**连点三次**齐套检查,每次都记录 `lack_qty` 汇总:
+
+```sql
+SELECT SUM(c.lack_qty) AS lack_total
+FROM b_bom_child_examine c JOIN b_examine_result e ON e.Id = c.examine_id
+WHERE e.morder_no = 'M500000004' AND e.IsDeleted = 0 AND c.is_use = 1;
+```
+
+**期望:三次结果完全相同**(不递增)。
+
+### 11.5 手动检查不写占用(C-D3)
+
+```sql
+-- 点齐套检查前后各跑一次,期望两次结果一致
+SELECT COUNT(*) AS occ_rows, IFNULL(SUM(quantity), 0) AS occ_qty
+FROM ic_item_stockoccupy WHERE morder_mo = 'M500000004' AND IsDeleted = 0;
+
+SELECT COUNT(*) AS po_rows, IFNULL(SUM(OccupyQty), 0) AS po_qty
+FROM srm_po_occupy WHERE morder_mo = 'M500000004' AND IsDeleted = 0;
+```
+
+### 11.6 失效范围不越界(C-D4)
+
+1. 记录另一个工单(例如 `M500000003`)当前有效结果的 Id:
+   ```sql
+   SELECT Id, morder_no, IsDeleted FROM b_examine_result
+   WHERE morder_no = 'M500000003' AND IsDeleted = 0;
+   ```
+2. 对 `M500000004` 点齐套检查。
+3. 重跑第 1 步 SQL。**期望:`M500000003` 的行仍然 `IsDeleted = 0`,Id 不变。**
+
+补充验证"雷已排除":造一个 `BusinessID = 0` 的工单(UAT 库可用),对它点齐套检查,确认其他工单结果不受影响。
+
+```sql
+-- 造数前先备份 BusinessID;验证完还原
+UPDATE WorkOrdMaster SET BusinessID = 0 WHERE WorkOrd = '<某测试工单>';
+-- 点齐套检查后:
+SELECT COUNT(*) AS still_active FROM b_examine_result WHERE IsDeleted = 0;
+-- 期望:只有该测试工单的旧结果被失效,总数不会塌成 1
+```
+
+### 11.7 前端
+
+- 缺料工单点「齐套检查」:出现**黄色告警**(含缺料行数),列表**自动刷新**,物料状况列变为"缺料"。
+- 齐套工单点「齐套检查」:绿色成功提示,列表刷新,状态"齐套"。
+- 工单下达流程不变(缺料不阻断下达)。
+
+---
+
+## 12. 风险与回滚
+
+| 风险 | 影响 | 处置 |
+|---|---|---|
+| 统一净口径后缺料量普遍变大 | 用户觉得"以前齐套现在缺料了" | **这是修正而非退化**。验收 11.2 保证两条路径一致;对外说明口径变更并保留 11.3 的 `net_scope` 标记作为凭据 |
+| 无成功排产记录时 `bangId = 0` | 口径退化为毛口径 | 与改动前一致,不会更差。`net_scope = 0` 会如实记录 |
+| `InvalidatePreviousAsync` 改为按 `morder_no` 后失效面变大 | 同一工单跨订单行的历史结果一起失效 | 这是期望行为:同一工单只应有一条有效结论 |
+| 前端不再报错,用户忽略缺料 | 缺料被忽视 | 告警文案带缺料行数,且列表状态同步更新 |
+
+**回滚**:还原 5 个代码文件。`check_scope` / `net_scope` 列可留(可空,不影响旧代码)。
+
+---
+
+## 13. 明确不做
+
+- 不改 165 的表、存储过程、数据。
+- 不改库存来源(本库 vs 165)—— 那是 WP-B。
+- 不改下达的阻断规则(缺料仍可下达)。
+- 不改 BOM 展开算法、损耗/报废系数、虚拟件处理。
+- 不改在途 `PurOrdDetail` 的口径。
+- 不清理 `b_examine_result` / `b_bom_child_examine` 的历史数据。
+- 不做缺料明细的新页面/弹窗。
+
+---
+
+## 14. 版本号(强制)
+
+本包**同时**包含后端代码 + 后端迁移脚本 + 前端代码,因此同一提交内**两端都要递增**:
+
+- `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>` 三处同号 patch +1
+- `Web/package.json` 的 `version` patch +1(当前 `2.4.285`,提交时按仓库实际值末位 +1)
+
+若前端与后端分两次提交,则各自只递增本次纳入提交的那一端。

+ 625 - 0
doc/plan/UAT缺陷整治任务书/WP-D-计划实绩合并.md

@@ -0,0 +1,625 @@
+# WP-D|工序实绩落地与计划/实绩合并(D-M01)
+
+> **状态:定案版(可直接执行)**。所有决策已锁,无需再确认。
+> 执行者:熟悉 C# / SqlSugar / MySQL 的模型或工程师。预计 1 天。
+> 前置:无。可与 WP-A / WP-B / WP-C 并行(与 WP-B 均改 165 只读链路,但文件不重叠)。
+
+---
+
+## 0. 本包定案
+
+| 编号 | 决策项 | 定案 | 依据 / 推翻条件 |
+|---|---|---|---|
+| D-D1 | 合并粒度 | **`(tenant_id, Domain, WorkOrds, Op)`**;不含 `Period`、`Line`、`PlanDate` | 实测同一工序的实绩会分散在 `Period=0` 影子行与 `Period=1` 计划行、且跨 `Line`(B01→ZJ01 重排)。只有工序级宽键能把它们汇到一起,且与 `WorkOrdRouting` 的工序级累计口径对齐。推翻条件:出现同工单同工序需要按日分别统计实绩的需求 |
+| D-D2 | 实绩存放位置 | **新建独立表 `ado_psd_op_actual`**,不写 `PeriodSequenceDet` | 实测本库 M500000004 的 `PeriodSequenceDet` 有 40 行,每次重排新增一批、旧行置 `IsActive=0`。实绩写进 PSD 会在下次全量重排时丢失。独立表不受重排影响。推翻条件:无 |
+| D-D3 | 数据新鲜度 | **复用既有热回读**(`MdpHotWatchService` + `MdpHotWatchWorker`),不新建 MDP 同步服务、不新建定时任务 | 该管线已在生产运行,且**已经**把 165 的 `WorkOrdRouting.QtyComplete` 正确写回本库(实测 `UpdateUser='MDP_HOT'`,8/5 与 165 完全一致)。新建管线是重复建设 |
+| D-D4 | 数量口径权威源 | `CompQty` = **165 侧同工序所有 PSD 行求和**,并以 `WorkOrdRouting.QtyComplete` 做交叉校验 | 实测 Op501:影子行 3 + 计划行 5 = 8 = `QtyComplete` 8;Op601:影子行 5 + 计划行 0 = 5 = `QtyComplete` 5。两条独立证据互相印证 |
+| D-D5 | 进度看板 | **本包不动** `workOrderProgressDashboard` 相关服务与页面 | 看板已另有数据来源,同时改会失去归因能力。落地后另开任务评估 |
+| D-D6 | 单位 | `ado_psd_op_actual` 里 **`actual_time_sec` 存秒、`setup_time_hour` 存小时**,与 165 原始单位一致,列名自带单位 | 实测 165 同一行 `ActualTime=1574`(秒)与 `SetupTime=0.11417`(小时)单位不同。强行归一会掩盖来源;列名带单位可让下游不猜 |
+
+---
+
+## 1. 目标
+
+让 165 MES APP 上的工序执行实绩(完成数、报废数、实动工时、装机工时)**准确、完整地落到 Ai-DOP 本库**,并让「可执行日计划」列表按工序显示合并后的完成数。
+
+**验收锚点(M500000004,工序级)**:
+
+| 工序 | 165 PSD 行明细 | 期望合并结果 | `WorkOrdRouting.QtyComplete` 交叉校验 |
+|---|---|---|---|
+| Op 501 | 影子行 `CompQty=3`(RecID 3511291)+ 计划行 `CompQty=5`(RecID 3511344,`SetupTime=0.33694`) | `comp_qty = 8`,`setup_time_hour = 0.33694` | 8 ✔ |
+| Op 601 | 影子行 `CompQty=5`、`ActualTime=1574`、`SetupTime=0.11417`(RecID 3511410)+ 计划行 `CompQty=0` | `comp_qty = 5`,`actual_time_sec = 1574`,`setup_time_hour = 0.11417` | 5 ✔ |
+| Op 704 | 均为 0 | `comp_qty = 0` | 0 ✔ |
+
+---
+
+## 2. 缺陷事实(已核实,可直接引用)
+
+### 2.1 本库现状:实绩完全没落地
+
+```sql
+-- aidopdev:M500000004 的 40 行 PSD 全部 CompQty=0、SetupTime=NULL、UpdateUser 是 Ai-DOP 排产账号
+SELECT COUNT(*) AS rows_cnt,
+       SUM(IFNULL(CompQty,0)) AS comp_sum,
+       COUNT(SetupTime) AS setup_notnull,
+       GROUP_CONCAT(DISTINCT UpdateUser) AS users
+FROM PeriodSequenceDet WHERE WorkOrds = 'M500000004';
+-- 实测:rows_cnt=40, comp_sum=0.0000, setup_notnull=0, users='AIDopAdmin,AIDOPDemo'
+```
+
+### 2.2 同样的实绩已经通过热回读落到了 `WorkOrdRouting`
+
+```sql
+-- aidopdev:同一工单的工序累计完成数已是正确值,写入者是热回读
+SELECT OP, QtyComplete, UpdateUser, UpdateTime
+FROM WorkOrdRouting WHERE WorkOrd = 'M500000004' AND OP IN (501, 601, 704);
+-- 实测:501→8, 601→5, 704→0,UpdateUser='MDP_HOT'
+```
+
+**结论**:管线是通的,只是**热回读的关注表清单里没有 `PeriodSequenceDet`**,所以工序级实绩(尤其 `ActualTime` 实动工时)从未进入本库。这就是 D-M01 的完整根因。
+
+### 2.3 165 侧的影子行现象(为什么必须按工序合并)
+
+```sql
+-- dopdemorq:同一工序的实绩分散在两类行上
+SELECT RecID, Op, Line, Period, CompQty, SetupTime, ActualTime, Status, IsActive
+FROM PeriodSequenceDet WHERE WorkOrds = 'M500000004' ORDER BY Op, Period;
+```
+
+实测要点:
+
+- MES APP 报工时会新增 `Period = 0`、`Sequence = 0`、`IsActive = 0`、`Status` 为 `'X'` 或空的**影子行**,执行数据(`CompQty` / `SetupTime` / `ActualTime`)写在影子行上。
+- 但**不总是**:Op 501 在 `Line='ZJ01'` 的 `Period=1` 行上也有 `CompQty=5`、`SetupTime=0.33694`。所以"实绩一定在影子行"是错的,**必须把同工序所有行一起汇总**。
+- 同一工序可能跨多个 `Line`(Op 501 同时有 B01 与 ZJ01 行,重排换线留下的),按 `Line` 分组会漏。
+- `ActualTime` 单位是**秒**(Op601 = 1574 秒 ≈ 26.2 分钟),`SetupTime` 单位是**小时**(0.11417 h ≈ 6.85 分钟)。同一行两种单位。
+
+### 2.4 `mdp_entity` 业务键会导致影子行覆盖计划行
+
+```sql
+SELECT entity_code, source_table_name, target_table_name, biz_key_expr, status
+FROM mdp_entity WHERE source_table_name = 'PeriodSequenceDet';
+```
+
+实测:`S2_PERIOD_SEQUENCE_SQLSERVER`(id=11063,**status=1 已启用**,尽管 remark 写着 disabled)的
+`biz_key_expr = 'Domain,ItemNum,PlanDate,Line,Op'`,**不含 `WorkOrds`、不含 `Period`**。
+
+后果:一旦热回读开始写 PSD 到贴源层,同一 `Domain+ItemNum+PlanDate+Line+Op` 的影子行与计划行会算出**同一个 `source_biz_key`**,命中 `mdp_stg_schedule` 的 `uk_source_key` 后互相覆盖,实绩或计划必丢一份。**这个键必须先修,否则第 4 步一上线就丢数。**
+
+### 2.5 实体缺字段映射
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs` 没有 `ActualTime` 属性,但本库表有 `ActualTime decimal(15,3)`。用该实体读写都会静默丢掉这一列。
+
+---
+
+## 3. 第 1 步:事实复核(必须先做)
+
+按顺序执行 2.1 / 2.2 / 2.3 / 2.4 四段 SQL,**逐条与"实测"值比对**。
+
+- 若 2.2 的 `UpdateUser` 不是 `MDP_HOT`,说明热回读没在跑:先检查 `MdpHotWatchWorker` 是否随后端启动、`mdp_source` 里 `DOPDEMORQ_SQLSERVER` 是否 `status=1`,修通后再继续。**热回读不通,本包后续步骤全部无效。**
+- 若 2.4 的 `biz_key_expr` 已包含 `WorkOrds` 与 `Period`,说明已有人改过,跳过第 2 步。
+- 数字与上文不一致是正常的(UAT 在继续跑);重要的是**结构性事实**成立:本库 PSD 实绩为 0、`WorkOrdRouting` 有值、165 存在影子行。
+
+---
+
+## 4. 第 2 步:数据库脚本
+
+新建 `server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.sql`。
+
+> **迁移脚本的四条硬约束**(来自 `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`,违反会导致脚本不执行或后端启动失败):
+> 1. **行尾必须 LF**。CRLF 会改变 SHA256,与 `sys_db_migration_log` 已记录的 hash 不一致时直接抛异常阻止启动。
+> 2. **脚本号必须 ≤ `.csproj` 的 `<Version>`**(`pending = scripts.Where(s => s.ParsedVersion <= currentVersion)`)。当前后端版本 **1.0.327**,已占用脚本号最大为 **1.0.323**,所以 `1.0.324` 可用。若本文写的号已被占用,取下一个未占用且 ≤ 当前版本的号,并同步更新 README。
+> 3. 脚本内**不得出现 `DELIMITER` 字样**(含注释里),执行器直接拒绝。
+> 4. 已成功执行过的脚本**不可再改内容**(hash 校验),需要修正就新建更高版本号的脚本。
+
+```sql
+-- 1.0.324.sql  WP-D 工序实绩落地:修正 165 PSD 贴源业务键 + 新建工序实绩表
+--   D-M01:MES APP 报工产生 Period=0 影子行,实绩分散;本脚本为工序级合并提供落地表。
+--   行尾必须 LF(CRLF 会导致 SHA256 与 sys_db_migration_log 不一致并阻止启动)。
+SET NAMES utf8mb4;
+
+-- 1) 修正 165 PeriodSequenceDet 贴源业务键:补 WorkOrds 与 Period,避免影子行覆盖计划行
+UPDATE mdp_entity
+SET biz_key_expr = 'Domain,WorkOrds,ItemNum,PlanDate,Line,Op,Period',
+    remark = 'WP-D 修正业务键:补 WorkOrds/Period,避免 MES 影子行与计划行 biz_key 冲突',
+    update_time = NOW()
+WHERE entity_code = 'S2_PERIOD_SEQUENCE_SQLSERVER';
+
+-- 2) 工序实绩表(工序级宽键,不受排产重排影响)
+CREATE TABLE IF NOT EXISTS ado_psd_op_actual (
+    id              BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键',
+    tenant_id       BIGINT        NOT NULL DEFAULT 0 COMMENT '租户',
+    domain          VARCHAR(80)   NOT NULL DEFAULT '' COMMENT '账套(165 Domain)',
+    work_ord        VARCHAR(64)   NOT NULL COMMENT '工单号(165 PeriodSequenceDet.WorkOrds)',
+    op              INT           NOT NULL DEFAULT 0 COMMENT '工序号',
+    item_num        VARCHAR(30)   NULL COMMENT '物料号(取自 165 同工序任一行,仅供展示)',
+    comp_qty        DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '完成数:165 同工序全部 PSD 行 SUM(CompQty)',
+    reject_qty      DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '报废数:同上 SUM(RejectQty)',
+    setup_time_hour DECIMAL(18,5) NOT NULL DEFAULT 0 COMMENT '装机工时,单位小时(165 SetupTime 原始单位)',
+    actual_time_sec DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '实动工时,单位秒(165 ActualTime 原始单位)',
+    src_row_count   INT           NOT NULL DEFAULT 0 COMMENT '参与合并的 165 行数(便于排查影子行)',
+    routing_qty     DECIMAL(18,4) NULL COMMENT '交叉校验值:165 WorkOrdRouting.QtyComplete',
+    last_src_update DATETIME      NULL COMMENT '165 侧最大 UpdateTime',
+    sync_time       DATETIME      NOT NULL COMMENT '本次写入时间',
+    create_time     DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    update_time     DATETIME(3)   NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_psd_op_actual (tenant_id, domain, work_ord, op),
+    KEY idx_psd_op_actual_wo (work_ord, op),
+    KEY idx_psd_op_actual_sync (sync_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工序实绩(165 PSD 工序级合并结果)';
+```
+
+同时新建**校验脚本** `server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.verify.sql`(同样 LF)。执行器会在主脚本成功后逐条跑它,任一条返回 0/NULL 即判为失败并写入 `verify_status=Failed`:
+
+```sql
+SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_psd_op_actual';
+SELECT COUNT(*) FROM mdp_entity
+ WHERE entity_code = 'S2_PERIOD_SEQUENCE_SQLSERVER'
+   AND biz_key_expr = 'Domain,WorkOrds,ItemNum,PlanDate,Line,Op,Period';
+```
+
+---
+
+## 5. 第 3 步:补 `PeriodSequenceDet` 实体的 `ActualTime` 映射
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs`
+
+**定位锚点**:
+
+```csharp
+    [SugarColumn(ColumnName = "SetupTime", IsNullable = true)]
+    public decimal? SetupTime { get; set; }
+```
+
+**在其后插入**:
+
+```csharp
+    /// <summary>实动工时,单位秒(165 原始单位;与 SetupTime 的小时不同)</summary>
+    [SugarColumn(ColumnName = "ActualTime", IsNullable = true)]
+    public decimal? ActualTime { get; set; }
+```
+
+**不要**改 `SetupTime` 的类型或注释,**不要**给实体加 `[SugarTable]` 之外的特性。
+
+---
+
+## 6. 第 4 步:热回读接入 `PeriodSequenceDet`(本包核心)
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs`
+
+### 6.1 把 PSD 加入 WORK_ORDER 的关注表
+
+该文件里有 **3 处**声明工单关注表的数组,全部要加 `"PeriodSequenceDet"`。逐处替换:
+
+**第 1 处**(`TryEnrollFromOutboxSuccessAsync` 内):
+
+```csharp
+        return EnrollAsync(
+            "WORK_ORDER",
+            workOrd,
+            domain,
+            new[] { "WorkOrdMaster", "WorkOrdRouting" },
+            item.TenantId,
+            ct: ct);
+```
+
+替换为:
+
+```csharp
+        return EnrollAsync(
+            "WORK_ORDER",
+            workOrd,
+            domain,
+            new[] { "WorkOrdMaster", "WorkOrdRouting", "PeriodSequenceDet" },
+            item.TenantId,
+            ct: ct);
+```
+
+**第 2 处**(`EnrollPickBillAsync` 内,工单那段):
+
+```csharp
+            await EnrollAsync(
+                "WORK_ORDER",
+                workOrd.Trim(),
+                domain,
+                new[] { "WorkOrdMaster", "WorkOrdRouting" },
+                tenantId,
+                ct: ct);
+```
+
+替换为:
+
+```csharp
+            await EnrollAsync(
+                "WORK_ORDER",
+                workOrd.Trim(),
+                domain,
+                new[] { "WorkOrdMaster", "WorkOrdRouting", "PeriodSequenceDet" },
+                tenantId,
+                ct: ct);
+```
+
+**第 3 处**:检查 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchAdminService.cs` 的 `EnrollWorkOrderInput` 处理逻辑,若那里也硬编码了表数组,同样补上。
+
+> 已登记的历史行不会自动生效(`watch_tables` 是登记时快照)。第 7 步给出补登记办法。
+
+### 6.2 让窄查询支持 PSD
+
+**定位锚点**(`QueryByBizKeyAsync` 的 `switch` 内):
+
+```csharp
+            case "WORKORDROUTING":
+                sql = "SELECT * FROM WorkOrdRouting WHERE Domain=@d AND WorkOrd=@k";
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
+```
+
+**在其后插入**:
+
+```csharp
+            case "PERIODSEQUENCEDET":
+                // 工序间衔接:按工单取全部行(含 MES 报工产生的 Period=0 影子行),由调用方按工序合并
+                sql = "SELECT * FROM PeriodSequenceDet WHERE Domain=@d AND WorkOrds=@k";
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
+```
+
+### 6.3 合并并落库
+
+**定位锚点**(`PollOnceAsync` 内,WORK_ORDER 的回写段):
+
+```csharp
+                    // 工单执行量写回本库业务表,供看板直接读取
+                    if (string.Equals(watch.BizType, "WORK_ORDER", StringComparison.OrdinalIgnoreCase))
+                    {
+                        tableRows.TryGetValue("WorkOrdMaster", out var masters);
+                        tableRows.TryGetValue("WorkOrdRouting", out var routings);
+                        var effectiveTenantId = watch.TenantId;
+                        if (effectiveTenantId <= 0)
+                            effectiveTenantId = await ResolveLocalWorkOrdTenantAsync(watch.BizKey, watch.Domain, ct);
+                        await ApplyWorkOrderExecutionAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, masters, routings, ct);
+                    }
+```
+
+**替换为**:
+
+```csharp
+                    // 工单执行量写回本库业务表,供看板直接读取
+                    if (string.Equals(watch.BizType, "WORK_ORDER", StringComparison.OrdinalIgnoreCase))
+                    {
+                        tableRows.TryGetValue("WorkOrdMaster", out var masters);
+                        tableRows.TryGetValue("WorkOrdRouting", out var routings);
+                        tableRows.TryGetValue("PeriodSequenceDet", out var periodDets);
+                        var effectiveTenantId = watch.TenantId;
+                        if (effectiveTenantId <= 0)
+                            effectiveTenantId = await ResolveLocalWorkOrdTenantAsync(watch.BizKey, watch.Domain, ct);
+                        await ApplyWorkOrderExecutionAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, masters, routings, ct);
+                        await ApplyPeriodSequenceActualAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, periodDets, routings, ct);
+                    }
+```
+
+**并在 `ApplyWorkOrderExecutionAsync` 方法之后新增以下方法**(可整段粘贴,只用到该文件已有的 `GetDecimal` / `TryGetInt` / `GetString` 私有辅助方法):
+
+```csharp
+    /// <summary>
+    /// D-M01:把 165 的工序实绩按 (Domain, WorkOrds, Op) 合并后落到 ado_psd_op_actual。
+    ///
+    /// 为什么按工序合并:MES APP 报工会新增 Period=0/IsActive=0 的影子行,实绩可能写在影子行、
+    /// 也可能写在 Period=1 计划行,且同一工序可能因重排跨多个 Line。只有工序级求和才与
+    /// WorkOrdRouting.QtyComplete(MES 自己的工序累计完成数)对齐。实测 Op501:3+5=8=QtyComplete。
+    ///
+    /// 为什么不写 PeriodSequenceDet:本库 PSD 行会被排产整表重建(旧行置 IsActive=0),
+    /// 实绩写进去会在下次全量重排时丢失。
+    ///
+    /// 单位保持 165 原始口径:ActualTime 秒、SetupTime 小时(列名自带单位)。
+    /// </summary>
+    private async Task ApplyPeriodSequenceActualAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        List<Dictionary<string, object>>? periodDets,
+        List<Dictionary<string, object>>? routings,
+        CancellationToken ct)
+    {
+        if (periodDets is not { Count: > 0 })
+            return;
+
+        // WorkOrdRouting.QtyComplete 作为交叉校验值(仅记录,不用于覆盖)
+        var routingQty = new Dictionary<int, decimal>();
+        if (routings != null)
+        {
+            foreach (var r in routings)
+            {
+                if (!TryGetInt(r, "OP", out var rop) && !TryGetInt(r, "Op", out rop))
+                    continue;
+                routingQty[rop] = GetDecimal(r, "QtyComplete");
+            }
+        }
+
+        var grouped = new Dictionary<int, PsdOpActual>();
+        foreach (var row in periodDets)
+        {
+            if (!TryGetInt(row, "Op", out var op) && !TryGetInt(row, "OP", out op))
+                continue;
+
+            if (!grouped.TryGetValue(op, out var acc))
+            {
+                acc = new PsdOpActual { Op = op };
+                grouped[op] = acc;
+            }
+
+            acc.CompQty += GetDecimal(row, "CompQty");
+            acc.RejectQty += GetDecimal(row, "RejectQty");
+            acc.SetupTimeHour += GetDecimal(row, "SetupTime");
+            acc.ActualTimeSec += GetDecimal(row, "ActualTime");
+            acc.SrcRowCount++;
+            acc.ItemNum ??= GetString(row, "ItemNum");
+
+            if (row.TryGetValue("UpdateTime", out var upd) && upd is DateTime dt
+                && (acc.LastSrcUpdate == null || dt > acc.LastSrcUpdate))
+                acc.LastSrcUpdate = dt;
+        }
+
+        var now = DateTime.Now;
+        var mismatches = new List<string>();
+
+        foreach (var acc in grouped.Values)
+        {
+            var rq = routingQty.TryGetValue(acc.Op, out var q) ? (decimal?)q : null;
+            if (rq.HasValue && rq.Value != acc.CompQty)
+                mismatches.Add($"Op{acc.Op}: psdSum={acc.CompQty} routing={rq.Value}");
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_psd_op_actual (
+                    tenant_id, domain, work_ord, op, item_num,
+                    comp_qty, reject_qty, setup_time_hour, actual_time_sec,
+                    src_row_count, routing_qty, last_src_update, sync_time
+                ) VALUES (
+                    @TenantId, @Domain, @WorkOrd, @Op, @ItemNum,
+                    @CompQty, @RejectQty, @SetupTimeHour, @ActualTimeSec,
+                    @SrcRowCount, @RoutingQty, @LastSrcUpdate, @Now
+                )
+                ON DUPLICATE KEY UPDATE
+                    item_num = VALUES(item_num),
+                    comp_qty = VALUES(comp_qty),
+                    reject_qty = VALUES(reject_qty),
+                    setup_time_hour = VALUES(setup_time_hour),
+                    actual_time_sec = VALUES(actual_time_sec),
+                    src_row_count = VALUES(src_row_count),
+                    routing_qty = VALUES(routing_qty),
+                    last_src_update = VALUES(last_src_update),
+                    sync_time = VALUES(sync_time)
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@Domain", domain ?? ""),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@Op", acc.Op),
+                new SugarParameter("@ItemNum", acc.ItemNum ?? (object)DBNull.Value),
+                new SugarParameter("@CompQty", acc.CompQty),
+                new SugarParameter("@RejectQty", acc.RejectQty),
+                new SugarParameter("@SetupTimeHour", acc.SetupTimeHour),
+                new SugarParameter("@ActualTimeSec", acc.ActualTimeSec),
+                new SugarParameter("@SrcRowCount", acc.SrcRowCount),
+                new SugarParameter("@RoutingQty", rq.HasValue ? rq.Value : (object)DBNull.Value),
+                new SugarParameter("@LastSrcUpdate", acc.LastSrcUpdate ?? (object)DBNull.Value),
+                new SugarParameter("@Now", now));
+        }
+
+        if (mismatches.Count > 0)
+        {
+            // 不阻断落库:差异说明 165 侧口径需人工确认,先留证
+            _logger.LogWarning(
+                "[MdpHotWatch] PSD 实绩与 WorkOrdRouting 不一致 wo={WorkOrd} domain={Domain} {Detail}",
+                workOrd, domain, string.Join("; ", mismatches));
+        }
+
+        _logger.LogInformation(
+            "[MdpHotWatch] applied PSD actual wo={WorkOrd} domain={Domain} ops={Ops} srcRows={Rows}",
+            workOrd, domain, grouped.Count, periodDets.Count);
+    }
+
+    private sealed class PsdOpActual
+    {
+        public int Op { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal CompQty { get; set; }
+        public decimal RejectQty { get; set; }
+        public decimal SetupTimeHour { get; set; }
+        public decimal ActualTimeSec { get; set; }
+        public int SrcRowCount { get; set; }
+        public DateTime? LastSrcUpdate { get; set; }
+    }
+```
+
+### 6.4 注意事项
+
+- `QueryByBizKeyAsync` 在同一轮里会被调用**两次**(一次算哈希、一次取数)。这是现有设计,**不要**为了优化去改它——那会牵动全部业务类型。
+- PSD 行没有 `RecID` 之外的稳定标识;贴源层写入沿用现有 `_staging.UpsertAsync` 逻辑,靠第 2 步修好的 `biz_key_expr` 保证不覆盖。
+- `ResolveEntityAsync("PeriodSequenceDet")` 会命中 `S2_PERIOD_SEQUENCE_SQLSERVER`(已 `status=1`),无需新建实体。
+
+---
+
+## 7. 第 5 步:给已有工单补登记热关注
+
+已下达的工单其 `watch_tables` 里没有 PSD,需要补一次。**只对 UAT 在用的工单做**,不要全库刷。
+
+```powershell
+# 替换 workOrd / domain / tenantId 为实际值;接口是 AllowAnonymous 的运维接口
+curl -X POST "http://localhost:5005/api/aidop/mdp-hot-watch/enroll-work-order" `
+  -H "Content-Type: application/json" `
+  -d '{"workOrd":"M500000004","domain":"8010","tenantId":797403760988229}'
+```
+
+若已存在 `status=0` 的同键登记,`EnrollAsync` 会直接返回、**不会**更新 `watch_tables`。此时先终结旧登记再重登:
+
+```sql
+-- 终结旧登记(只针对目标工单)
+UPDATE ado_mdp_hot_watch
+SET status = 1, terminate_time = NOW(), terminate_reason = 'WP-D re-enroll', update_time = NOW()
+WHERE biz_type = 'WORK_ORDER' AND biz_key = 'M500000004' AND status = 0;
+```
+
+再调一次 enroll,然后强制轮询一次:
+
+```powershell
+curl -X POST "http://localhost:5005/api/aidop/mdp-hot-watch/poll-once?take=100"
+```
+
+> 端口以实际启动端口为准(见 `launchSettings.json` 或启动日志)。表名 `ado_mdp_hot_watch` 以 `AdoMdpHotWatch` 实体的 `SugarTable` 为准,执行前先确认。
+
+---
+
+## 8. 第 6 步:读端改为使用合并后的实绩
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs`
+
+### 8.1 完成数取合并值
+
+**定位锚点**(`BuildListBaseSql` 开头两行):
+
+```sql
+            IF(p.CompQty > 0, p.CompQty, IF(IFNULL(comPD.CompQty, 0) > 0, comPD.CompQty, 0)) AS CompQty,
+            IF(p.RejectQty > 0, p.RejectQty, IF(IFNULL(comPD.RejectQty, 0) > 0, comPD.RejectQty, 0)) AS RejectQty,
+```
+
+**替换为**:
+
+```sql
+            GREATEST(IFNULL(p.CompQty, 0), IFNULL(act.comp_qty, 0)) AS CompQty,
+            GREATEST(IFNULL(p.RejectQty, 0), IFNULL(act.reject_qty, 0)) AS RejectQty,
+```
+
+### 8.2 换掉自关联派生表
+
+**定位锚点**(同方法末尾的 `comPD` 关联):
+
+```sql
+        LEFT JOIN (
+            SELECT SUM(CompQty) AS CompQty, SUM(RejectQty) AS RejectQty, PlanDate, `Period` AS UInt2, Op, WorkOrds, ItemNum
+            FROM PeriodSequenceDet
+            WHERE CompQty > 0
+            GROUP BY PlanDate, `Period`, Op, WorkOrds, ItemNum
+        ) comPD ON DATE_FORMAT(p.PlanDate,'%Y-%m-%d') = DATE_FORMAT(comPD.PlanDate,'%Y-%m-%d')
+            AND p.`Period` = comPD.UInt2 AND p.Op = comPD.Op AND p.WorkOrds = comPD.WorkOrds AND p.ItemNum = comPD.ItemNum
+```
+
+**替换为**:
+
+```sql
+        LEFT JOIN ado_psd_op_actual act
+               ON act.tenant_id = IFNULL(p.tenant_id, 0)
+              AND act.domain = IFNULL(p.`Domain`, '')
+              AND act.work_ord = p.WorkOrds
+              AND act.op = p.Op
+```
+
+两点收益:一是完成数不再依赖"实绩恰好落在同 `Period` 同 `PlanDate` 的行"这个不成立的假设;二是去掉了每次列表查询都要对 `PeriodSequenceDet` 全表 `GROUP BY` 的派生表。
+
+### 8.3 不要顺手做的事
+
+- **不要**在 SELECT 里加 `actual_time_sec` / `setup_time_hour` 列。DTO 没有对应属性,加了也不显示,还会让人以为已经交付了工时展示。页面显示实动工时是独立需求,另开任务。
+- **不要**改 `SetupTime` / `SetupTimeInMinutes`(来自 `ScheduleResultOpMaster`,是排程计划值,不是实绩)。
+- **不要**动 `DeviceWorkHours` / `ManWorkHours` 的算法。
+
+---
+
+## 9. 验收
+
+### 9.1 编译与启动
+
+```powershell
+dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj -c Debug
+```
+
+启动后确认迁移脚本成功登记:
+
+```sql
+SELECT * FROM sys_db_migration_log WHERE file_name LIKE '1.0.324%';
+```
+
+### 9.2 业务键已修
+
+```sql
+-- 期望:biz_key_expr = 'Domain,WorkOrds,ItemNum,PlanDate,Line,Op,Period'
+SELECT entity_code, biz_key_expr FROM mdp_entity WHERE entity_code = 'S2_PERIOD_SEQUENCE_SQLSERVER';
+```
+
+### 9.3 实绩已落地且与 165 一致(核心验收)
+
+```sql
+-- 期望:501→comp_qty=8, setup_time_hour=0.33694;601→comp_qty=5, actual_time_sec=1574, setup_time_hour=0.11417
+SELECT op, comp_qty, reject_qty, setup_time_hour, actual_time_sec,
+       src_row_count, routing_qty, last_src_update, sync_time
+FROM ado_psd_op_actual
+WHERE work_ord = 'M500000004'
+ORDER BY op;
+```
+
+**必须同时满足 `comp_qty = routing_qty`**(每一行)。若不等,`sys_log_ex` / 应用日志里会有 `PSD 实绩与 WorkOrdRouting 不一致` 的 warning,按日志里的 `psdSum` / `routing` 值定位是哪一工序、去 165 查该工序的全部 PSD 行确认是否有第三类行未被纳入。
+
+### 9.4 贴源层没有互相覆盖
+
+```sql
+-- 期望:同一 (Op, Period) 组合各自独立成行,Period=0 与 Period=1 都在
+SELECT source_biz_key,
+       JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.Op')) AS op,
+       JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.Period')) AS period,
+       JSON_UNQUOTE(JSON_EXTRACT(raw_data, '$.CompQty')) AS comp_qty
+FROM mdp_stg_schedule
+WHERE source_system = 'DOPDEMORQ_SQLSERVER'
+  AND source_table = 'PeriodSequenceDet'
+  AND source_biz_key LIKE '%M500000004%'
+ORDER BY op, period;
+```
+
+### 9.5 页面
+
+打开「可执行日计划」列表,筛到 M500000004:
+
+- Op 501 行完成数显示 **8**,Op 601 行显示 **5**,Op 704 行显示 **0**。
+- 列表响应时间不慢于改动前(去掉派生表后应更快)。
+- 其他工单的行不出现"完成数凭空变化"。抽查 3 个未在 165 报工过的工单,完成数应仍为 0。
+
+### 9.6 回归
+
+- 领料单创建 → 下达流程仍正常(热回读登记路径被改过)。
+- `ado_mdp_hot_watch` 里 WORK_ORDER 行的 `watch_tables` 含三张表:`["WorkOrdMaster","WorkOrdRouting","PeriodSequenceDet"]`。
+- 165 侧**无任何写入**:`ApplyPeriodSequenceActualAsync` 只读 `periodDets`(内存里的行)、只写本库。检查 `git diff` 确认没有对 `remote` 客户端的 `ExecuteCommand`。
+
+---
+
+## 10. 风险与回滚
+
+| 风险 | 影响 | 处置 |
+|---|---|---|
+| 165 PSD 单工单行数很大(重排历史残留),每轮 `SELECT *` 两次 | 热回读变慢 | 实测单工单 14 行、本库 40 行,量级安全。若某工单超过 500 行,给 `QueryByBizKeyAsync` 的 PSD 分支加 `AND (IsActive = 1 OR CompQty > 0 OR ActualTime > 0)` 收窄 |
+| `comp_qty` 与 `routing_qty` 长期不一致 | 实绩口径不可信 | 已有 warning 日志。不一致时**以 `WorkOrdRouting` 为准对外说明**,并把差异样本提给 165 维护方 |
+| 读端 `GREATEST` 改动影响其他工单显示 | 完成数虚高 | `ado_psd_op_actual` 无数据时 `act.comp_qty` 为 NULL → `IFNULL` 归 0 → 取 `p.CompQty`,与改动前等价 |
+
+**回滚**:还原 3 个 `.cs` 文件即可;`ado_psd_op_actual` 可留(无人读则无影响)。`biz_key_expr` 若要还原,执行
+`UPDATE mdp_entity SET biz_key_expr='Domain,ItemNum,PlanDate,Line,Op' WHERE entity_code='S2_PERIOD_SEQUENCE_SQLSERVER';`
+但**不建议**——旧键本身是缺陷。
+
+---
+
+## 11. 明确不做
+
+- 不改 165 的表、存储过程、数据。
+- 不新建 MDP 同步服务、不新建定时任务(复用热回读)。
+- 不动进度看板(`workOrderProgressDashboard*`)与排产服务(`ProductionScheduleGenerationService`)。
+- 不在页面上展示实动工时 / 装机工时(数据已落库,展示另开任务)。
+- 不清理本库 PSD 的历史残留行(40 行里大量 `IsActive=0`、`PlanDate` 为 2027/2028/2029 的重排遗留)。这是排产侧的独立问题,另开任务。
+- 不做 `ActualTime` 秒↔小时归一。
+
+---
+
+## 12. 版本号(强制)
+
+本包全部改动为**后端代码 + 后端迁移脚本**:在同一提交内把
+`server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>`
+**三处同号 patch +1**。**不要**动 `Web/package.json`。

+ 62 - 0
doc/plan/旧DOP-MES-WMS/缺陷-D-W01-未知标签初始化.md

@@ -0,0 +1,62 @@
+# 缺陷 D-W01:WMS 未知标签初始化不产生初始库存
+
+| 项 | 内容 |
+|----|------|
+| 提出方 | Ai-DOP 项目组 |
+| 提出日期 | 2026-08-11 |
+| 期望处理方 | 165 旧 DOP-MES-WMS 维护方 |
+| 严重级别 | 中(有绕行办法,但影响新条码首次建账) |
+| 环境 | SQL Server `dopdemorq`(165) |
+| 入口 | WMS「未知标签初始化」 |
+| 存储过程 | `pr_WMS_SaveUnknownLabelInit` |
+
+---
+
+## 1. 现象
+
+对**该物料+库位组合在 `InvMaster` 中尚无记录**的情况,执行「未知标签初始化」后**不产生任何初始库存行**,页面无报错,静默失败。
+
+影响:UAT 中新条码/新库位无法建立起始库存,导致后续出入库、齐套检查的库存基数为 0。
+
+复现步骤见 `doc/plan/UAT留证/2026-08-09-S1起真人测试/03-WMS-步骤记录.md` 中 D-W01 段落。
+
+---
+
+## 2. 根因
+
+存储过程中新增库存行的分支,把「目标库位是否已存在库存」的子查询写成了 `JOIN` 参与筛选。当目标组合**不存在**时,该 `JOIN` 无匹配行 → 结果集为空 → `INSERT ... SELECT` 插入 0 行。也就是说:**只有已经有库存的组合才能被「初始化」**,与功能语义相反。
+
+---
+
+## 3. 建议修法(供 165 维护方评估)
+
+把该分支从「JOIN 现存库存」改为「NOT EXISTS 现存库存则插入」,即:
+
+```sql
+-- 建议方向(伪码,需由 165 维护方按其实际 SP 上下文落地)
+INSERT INTO InvMaster (Domain, ItemNum, Location, LotSerial, QtyOnHand, AvailStatusQty, ...)
+SELECT @Domain, @ItemNum, @Location, @LotSerial, @Qty, @Qty, ...
+WHERE NOT EXISTS (
+    SELECT 1 FROM InvMaster m
+    WHERE m.Domain = @Domain AND m.ItemNum = @ItemNum
+      AND m.Location = @Location AND ISNULL(m.LotSerial, '') = ISNULL(@LotSerial, '')
+);
+
+-- 已存在则走累加分支
+UPDATE InvMaster
+SET QtyOnHand = QtyOnHand + @Qty, AvailStatusQty = AvailStatusQty + @Qty, ...
+WHERE Domain = @Domain AND ItemNum = @ItemNum
+  AND Location = @Location AND ISNULL(LotSerial, '') = ISNULL(@LotSerial, '');
+```
+
+同时建议:初始化 0 行时**抛业务错误**而非静默返回,避免现场以为成功。
+
+> Ai-DOP 侧硬约束:不修改 165 的任何存储过程、表结构或数据。本报告仅供移交。
+
+---
+
+## 4. UAT 现场绕行办法(二选一)
+
+**办法一(推荐)**:先用 WMS 已有的**正常入库**流程给该物料+库位建立第一笔库存,之后再用「未知标签初始化」补差异。已存在库存的组合走累加分支,功能正常。
+
+**办法二**:由 165 DBA 手工在 `InvMaster` 插入一条 `QtyOnHand = 0` 的占位行,再执行未知标签初始化。此办法需 165 侧授权,Ai-DOP 不代为执行。

+ 6 - 0
server/Admin.NET.Application/Configuration/App.json

@@ -57,6 +57,12 @@
   "AiDOP": {
     "PickBill": {
       "Mode": "Mes" // WP9 S4:Mes=165 权威建领料;Local=本库降级路径
+    },
+    "Stock": {
+      "Source": "Mes", // 齐套库存来源:Mes=直读 165 实时;Local=本库 InvMaster 快照(降级)
+      "MesDomain": "8010",
+      "MesTenantId": 797403760988229,
+      "BatchSize": 500
     }
   },
   // 雪花Id

+ 34 - 4
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -10,10 +10,10 @@
     <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
-    <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.312</AssemblyVersion>
-    <FileVersion>1.0.312</FileVersion>
-    <Version>1.0.312</Version>
+    <Description>Admin.NET 通用权限开发平台</Description>
+    <AssemblyVersion>1.0.326</AssemblyVersion>
+    <FileVersion>1.0.326</FileVersion>
+    <Version>1.0.326</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -292,6 +292,36 @@
     <None Update="UpdateScripts\1.0.309.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.317.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.318.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.319.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.321.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.322.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.323.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.324.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.324.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.325.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.325.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 35 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.sql

@@ -0,0 +1,35 @@
+-- 1.0.324.sql  WP-D 工序实绩落地:修正 165 PSD 贴源业务键 + 新建工序实绩表
+--   D-M01:MES APP 报工产生 Period=0 影子行,实绩分散;本脚本为工序级合并提供落地表。
+--   行尾必须 LF(CRLF 会导致 SHA256 与 sys_db_migration_log 不一致并阻止启动)。
+SET NAMES utf8mb4;
+
+-- 1) 修正 165 PeriodSequenceDet 贴源业务键:补 WorkOrds 与 Period,避免影子行覆盖计划行
+UPDATE mdp_entity
+SET biz_key_expr = 'Domain,WorkOrds,ItemNum,PlanDate,Line,Op,Period',
+    remark = 'WP-D 修正业务键:补 WorkOrds/Period,避免 MES 影子行与计划行 biz_key 冲突',
+    update_time = NOW()
+WHERE entity_code = 'S2_PERIOD_SEQUENCE_SQLSERVER';
+
+-- 2) 工序实绩表(工序级宽键,不受排产重排影响)
+CREATE TABLE IF NOT EXISTS ado_psd_op_actual (
+    id              BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键',
+    tenant_id       BIGINT        NOT NULL DEFAULT 0 COMMENT '租户',
+    domain          VARCHAR(80)   NOT NULL DEFAULT '' COMMENT '账套(165 Domain)',
+    work_ord        VARCHAR(64)   NOT NULL COMMENT '工单号(165 PeriodSequenceDet.WorkOrds)',
+    op              INT           NOT NULL DEFAULT 0 COMMENT '工序号',
+    item_num        VARCHAR(30)   NULL COMMENT '物料号(取自 165 同工序任一行,仅供展示)',
+    comp_qty        DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '完成数:165 同工序全部 PSD 行 SUM(CompQty)',
+    reject_qty      DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '报废数:同上 SUM(RejectQty)',
+    setup_time_hour DECIMAL(18,5) NOT NULL DEFAULT 0 COMMENT '装机工时,单位小时(165 SetupTime 原始单位)',
+    actual_time_sec DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '实动工时,单位秒(165 ActualTime 原始单位)',
+    src_row_count   INT           NOT NULL DEFAULT 0 COMMENT '参与合并的 165 行数(便于排查影子行)',
+    routing_qty     DECIMAL(18,4) NULL COMMENT '交叉校验值:165 WorkOrdRouting.QtyComplete',
+    last_src_update DATETIME      NULL COMMENT '165 侧最大 UpdateTime',
+    sync_time       DATETIME      NOT NULL COMMENT '本次写入时间',
+    create_time     DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    update_time     DATETIME(3)   NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_psd_op_actual (tenant_id, domain, work_ord, op),
+    KEY idx_psd_op_actual_wo (work_ord, op),
+    KEY idx_psd_op_actual_sync (sync_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工序实绩(165 PSD 工序级合并结果)';

+ 5 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.324.verify.sql

@@ -0,0 +1,5 @@
+SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_psd_op_actual';
+SELECT COUNT(*) FROM mdp_entity
+ WHERE entity_code = 'S2_PERIOD_SEQUENCE_SQLSERVER'
+   AND biz_key_expr = 'Domain,WorkOrds,ItemNum,PlanDate,Line,Op,Period';

+ 20 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.sql

@@ -0,0 +1,20 @@
+-- 1.0.325.sql  WP-C 齐套口径统一:为齐套结果头补来源标记
+--   D-A02:排产(净口径)与手动检查(毛口径)写同一张 b_examine_result 且互相覆盖,
+--          事后无法判断某条结论出自哪条路径。bangid 列早已存在但从未写入。
+--   幂等:经 information_schema 判列是否存在,可重复执行。
+--   行尾必须 LF(CRLF 会导致 SHA256 与 sys_db_migration_log 不一致并阻止启动)。
+SET NAMES utf8mb4;
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'check_scope'),
+  'SELECT 1',
+  'ALTER TABLE b_examine_result ADD COLUMN check_scope VARCHAR(20) NULL COMMENT ''触发来源:SCHEDULE=排产内嵌/RELEASE=工单下达/SINGLE=手动单工单/BATCH=批量/REVIEW=订单评审'' AFTER bangid'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'net_scope'),
+  'SELECT 1',
+  'ALTER TABLE b_examine_result ADD COLUMN net_scope TINYINT(1) NULL DEFAULT 0 COMMENT ''1=净口径(已扣同版本占用);0=毛口径'' AFTER check_scope'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;

+ 4 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.325.verify.sql

@@ -0,0 +1,4 @@
+SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'check_scope';
+SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'b_examine_result' AND COLUMN_NAME = 'net_scope';

+ 86 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchAdminService.cs

@@ -0,0 +1,86 @@
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
+
+/// <summary>热回读联调:手工登记 / 立即轮询。</summary>
+[ApiDescriptionSettings(Order = 329, Description = "MDP 热回读运维")]
+[Route("api/aidop/mdp-hot-watch")]
+[AllowAnonymous]
+[NonUnify]
+public class MdpHotWatchAdminService : IDynamicApiController, ITransient
+{
+    private readonly MdpHotWatchService _hotWatch;
+    private readonly ISqlSugarClient _db;
+
+    public MdpHotWatchAdminService(MdpHotWatchService hotWatch, ISqlSugarClient db)
+    {
+        _hotWatch = hotWatch;
+        _db = db;
+    }
+
+    public sealed class EnrollWorkOrderInput
+    {
+        public string WorkOrd { get; set; } = "";
+        public string? Domain { get; set; }
+        public long TenantId { get; set; }
+    }
+
+    [DisplayName("登记工单热回读")]
+    [HttpPost("enroll-work-order")]
+    public async Task<object> EnrollWorkOrder([FromBody] EnrollWorkOrderInput input, CancellationToken ct = default)
+    {
+        if (input == null || string.IsNullOrWhiteSpace(input.WorkOrd))
+            throw Oops.Oh("WorkOrd 不能为空");
+        var domain = string.IsNullOrWhiteSpace(input.Domain) ? "8010" : input.Domain.Trim();
+        await _hotWatch.EnrollAsync(
+            "WORK_ORDER",
+            input.WorkOrd.Trim(),
+            domain,
+            new[] { "WorkOrdMaster", "WorkOrdRouting", "PeriodSequenceDet" },
+            input.TenantId,
+            ct: ct);
+        return new { ok = true, workOrd = input.WorkOrd.Trim(), domain };
+    }
+
+    [DisplayName("热回读轮询一次")]
+    [HttpPost("poll-once")]
+    public async Task<object> PollOnce([FromQuery] int take = 100, CancellationToken ct = default)
+    {
+        var (polled, changed, terminated) = await _hotWatch.PollOnceAsync(take <= 0 ? 100 : take, ct);
+        return new { ok = true, polled, changed, terminated };
+    }
+
+    public sealed class ForceRepollInput
+    {
+        public string BizKey { get; set; } = "";
+        public string? BizType { get; set; }
+    }
+
+    /// <summary>清空快照哈希,下一轮 poll 必走变更写回(联调)。</summary>
+    [DisplayName("强制重读业务键")]
+    [HttpPost("force-repoll")]
+    public async Task<object> ForceRepoll([FromBody] ForceRepollInput input, CancellationToken ct = default)
+    {
+        if (input == null || string.IsNullOrWhiteSpace(input.BizKey))
+            throw Oops.Oh("BizKey 不能为空");
+        var key = input.BizKey.Trim();
+        var type = string.IsNullOrWhiteSpace(input.BizType) ? null : input.BizType.Trim();
+
+        var q = _db.Queryable<AdoMdpHotWatch>().Where(x => x.BizKey == key && x.Status == 0);
+        if (!string.IsNullOrWhiteSpace(type))
+            q = q.Where(x => x.BizType == type);
+
+        var rows = await q.ToListAsync(ct);
+        foreach (var w in rows)
+        {
+            w.LastSnapshotHash = null;
+            w.UpdateTime = DateTime.Now;
+            await _db.Updateable(w)
+                .UpdateColumns(x => new { x.LastSnapshotHash, x.UpdateTime })
+                .ExecuteCommandAsync(ct);
+        }
+
+        var (polled, changed, terminated) = await _hotWatch.PollOnceAsync(Math.Max(100, rows.Count), ct);
+        return new { ok = true, cleared = rows.Count, polled, changed, terminated };
+    }
+}

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

@@ -39,8 +39,17 @@ public sealed class MdpHotWatchService : ITransient
         string bizType, string bizKey, string domain, IEnumerable<string> watchTables,
         long tenantId = 0, int pollIntervalSec = 5, CancellationToken ct = default)
     {
+        bizType = (bizType ?? "").Trim();
+        bizKey = (bizKey ?? "").Trim();
+        domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
+        if (string.IsNullOrWhiteSpace(bizType) || string.IsNullOrWhiteSpace(bizKey))
+            return;
+
+        if (tenantId <= 0 && string.Equals(bizType, "WORK_ORDER", StringComparison.OrdinalIgnoreCase))
+            tenantId = await ResolveLocalWorkOrdTenantAsync(bizKey, domain, ct);
+
         var exists = await _db.Queryable<AdoMdpHotWatch>()
-            .Where(x => x.TenantId == tenantId && x.BizType == bizType && x.BizKey == bizKey && x.Status == 0)
+            .Where(x => x.BizType == bizType && x.BizKey == bizKey && x.Status == 0)
             .AnyAsync(ct);
         if (exists) return;
 
@@ -58,6 +67,81 @@ public sealed class MdpHotWatchService : ITransient
             CreateTime = now,
             UpdateTime = now
         }).ExecuteCommandAsync(ct);
+
+        _logger.LogInformation(
+            "[MdpHotWatch] enrolled type={Type} key={Key} domain={Domain} tenant={Tenant}",
+            bizType, bizKey, domain, tenantId);
+    }
+
+    /// <summary>
+    /// Outbox 推送成功后按 action/idem 登记 WORK_ORDER 热关注。
+    /// idem 约定:<c>wo|domain|workOrd|…</c> / <c>pick|domain|workOrd|…</c>
+    /// </summary>
+    public Task TryEnrollFromOutboxSuccessAsync(MdpOutbox item, CancellationToken ct = default)
+    {
+        if (item == null) return Task.CompletedTask;
+        if (!string.Equals(item.TargetSourceCode, SourceCode, StringComparison.OrdinalIgnoreCase))
+            return Task.CompletedTask;
+
+        var action = (item.ActionCode ?? "").Trim().ToUpperInvariant();
+        var isWo =
+            action.StartsWith("WO_MES_", StringComparison.Ordinal)
+            || action is "PICK_WOM_UPSERT" or "PICK_WOR_STATUS";
+        if (!isWo) return Task.CompletedTask;
+
+        if (!TryParseWoIdem(item.IdemKey, out var domain, out var workOrd))
+            return Task.CompletedTask;
+
+        return EnrollAsync(
+            "WORK_ORDER",
+            workOrd,
+            domain,
+            new[] { "WorkOrdMaster", "WorkOrdRouting", "PeriodSequenceDet" },
+            item.TenantId,
+            ct: ct);
+    }
+
+    /// <summary>领料单写入 165 成功后登记 PICK_BILL(及关联工单)热关注。</summary>
+    public async Task EnrollPickBillAsync(
+        string domain, string nbr, string? workOrd, long tenantId = 0, CancellationToken ct = default)
+    {
+        domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
+        nbr = (nbr ?? "").Trim();
+        if (string.IsNullOrWhiteSpace(nbr)) return;
+
+        await EnrollAsync(
+            "PICK_BILL",
+            nbr,
+            domain,
+            new[] { "NbrMaster", "NbrDetail", "MissedPrint" },
+            tenantId,
+            ct: ct);
+
+        if (!string.IsNullOrWhiteSpace(workOrd))
+        {
+            await EnrollAsync(
+                "WORK_ORDER",
+                workOrd.Trim(),
+                domain,
+                new[] { "WorkOrdMaster", "WorkOrdRouting", "PeriodSequenceDet" },
+                tenantId,
+                ct: ct);
+        }
+    }
+
+    private static bool TryParseWoIdem(string? idem, out string domain, out string workOrd)
+    {
+        domain = "8010";
+        workOrd = "";
+        if (string.IsNullOrWhiteSpace(idem)) return false;
+        var parts = idem.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        if (parts.Length < 3) return false;
+        if (!parts[0].Equals("wo", StringComparison.OrdinalIgnoreCase)
+            && !parts[0].Equals("pick", StringComparison.OrdinalIgnoreCase))
+            return false;
+        domain = string.IsNullOrWhiteSpace(parts[1]) ? "8010" : parts[1];
+        workOrd = parts[2];
+        return !string.IsNullOrWhiteSpace(workOrd);
     }
 
     /// <summary>取一批在途行并轮询 165。</summary>
@@ -119,10 +203,13 @@ public sealed class MdpHotWatchService : ITransient
                     watch.LastSnapshotHash = hash;
                     changed++;
 
+                    var tableRows = new Dictionary<string, List<Dictionary<string, object>>>(StringComparer.OrdinalIgnoreCase);
+
                     // 变更落地:按表写 stg(执行侧字段快照)
                     foreach (var table in tables)
                     {
                         var rows = await QueryByBizKeyAsync(remote, table, watch.Domain, watch.BizType, watch.BizKey, ct);
+                        tableRows[table] = rows;
                         var entity = await ResolveEntityAsync(table, ct);
                         if (entity == null) continue;
                         foreach (var row in rows)
@@ -144,6 +231,21 @@ public sealed class MdpHotWatchService : ITransient
                         }
                     }
 
+                    // 工单执行量写回本库业务表,供看板直接读取
+                    if (string.Equals(watch.BizType, "WORK_ORDER", StringComparison.OrdinalIgnoreCase))
+                    {
+                        tableRows.TryGetValue("WorkOrdMaster", out var masters);
+                        tableRows.TryGetValue("WorkOrdRouting", out var routings);
+                        tableRows.TryGetValue("PeriodSequenceDet", out var periodDets);
+                        var effectiveTenantId = watch.TenantId;
+                        if (effectiveTenantId <= 0)
+                            effectiveTenantId = await ResolveLocalWorkOrdTenantAsync(watch.BizKey, watch.Domain, ct);
+                        await ApplyWorkOrderExecutionAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, masters, routings, ct);
+                        await ApplyPeriodSequenceActualAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey, periodDets, routings, ct);
+                    }
+
                     if (await ShouldTerminateAsync(remote, watch, ct))
                     {
                         watch.Status = 1;
@@ -200,6 +302,11 @@ public sealed class MdpHotWatchService : ITransient
                 sql = "SELECT * FROM WorkOrdRouting WHERE Domain=@d AND WorkOrd=@k";
                 pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
                 break;
+            case "PERIODSEQUENCEDET":
+                // 工序间衔接:按工单取全部行(含 MES 报工产生的 Period=0 影子行),由调用方按工序合并
+                sql = "SELECT * FROM PeriodSequenceDet WHERE Domain=@d AND WorkOrds=@k";
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
             case "PURORDDETAIL":
                 sql = "SELECT * FROM PurOrdDetail WHERE Domain=@d AND PurOrd=@k";
                 pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
@@ -240,6 +347,259 @@ public sealed class MdpHotWatchService : ITransient
         return list;
     }
 
+    /// <summary>
+    /// 把 165 工单执行字段回写本库(MES 权威列:Status / QtyCompleted / QtyComplete / QtyReject)。
+    /// </summary>
+    private async Task ApplyWorkOrderExecutionAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        List<Dictionary<string, object>>? masters,
+        List<Dictionary<string, object>>? routings,
+        CancellationToken ct)
+    {
+        var now = DateTime.Now;
+        var updatedRouting = 0;
+
+        if (routings != null)
+        {
+            foreach (var row in routings)
+            {
+                if (!TryGetInt(row, "OP", out var op) && !TryGetInt(row, "Op", out op))
+                    continue;
+                var qtyComplete = GetDecimal(row, "QtyComplete");
+                var qtyReject = GetDecimal(row, "QtyReject");
+                var status = Trunc(GetString(row, "Status"), 1);
+
+                updatedRouting += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    UPDATE WorkOrdRouting
+                    SET QtyComplete = @QtyComplete,
+                        QtyReject = @QtyReject,
+                        Status = CASE WHEN IFNULL(@Status,'') = '' THEN Status ELSE @Status END,
+                        UpdateUser = 'MDP_HOT',
+                        UpdateTime = @Now
+                    WHERE WorkOrd = @WorkOrd
+                      AND OP = @Op
+                      AND IFNULL(Domain, '') = @Domain
+                      AND IFNULL(tenant_id, 0) = @TenantId
+                    """,
+                    new SugarParameter("@QtyComplete", qtyComplete),
+                    new SugarParameter("@QtyReject", qtyReject),
+                    new SugarParameter("@Status", status ?? ""),
+                    new SugarParameter("@Now", now),
+                    new SugarParameter("@WorkOrd", workOrd),
+                    new SugarParameter("@Op", op),
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@TenantId", tenantId));
+            }
+        }
+
+        if (masters is { Count: > 0 })
+        {
+            var m = masters[0];
+            var qtyCompleted = GetDecimal(m, "QtyCompleted");
+            var status = Trunc(GetString(m, "Status"), 8);
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE WorkOrdMaster
+                SET QtyCompleted = @QtyCompleted,
+                    Status = CASE WHEN IFNULL(@Status,'') = '' THEN Status ELSE @Status END,
+                    UpdateUser = 'MDP_HOT',
+                    UpdateTime = @Now
+                WHERE WorkOrd = @WorkOrd
+                  AND IFNULL(Domain, '') = @Domain
+                  AND IFNULL(tenant_id, 0) = @TenantId
+                """,
+                new SugarParameter("@QtyCompleted", qtyCompleted),
+                new SugarParameter("@Status", status ?? ""),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@Domain", domain),
+                new SugarParameter("@TenantId", tenantId));
+        }
+
+        _logger.LogInformation(
+            "[MdpHotWatch] applied WORK_ORDER execution wo={WorkOrd} domain={Domain} routingRows={Rows}",
+            workOrd, domain, updatedRouting);
+    }
+
+    /// <summary>
+    /// D-M01:把 165 的工序实绩按 (Domain, WorkOrds, Op) 合并后落到 ado_psd_op_actual。
+    ///
+    /// 为什么按工序合并:MES APP 报工会新增 Period=0/IsActive=0 的影子行,实绩可能写在影子行、
+    /// 也可能写在 Period=1 计划行,且同一工序可能因重排跨多个 Line。只有工序级求和才与
+    /// WorkOrdRouting.QtyComplete(MES 自己的工序累计完成数)对齐。实测 Op501:3+5=8=QtyComplete。
+    ///
+    /// 为什么不写 PeriodSequenceDet:本库 PSD 行会被排产整表重建(旧行置 IsActive=0),
+    /// 实绩写进去会在下次全量重排时丢失。
+    ///
+    /// 单位保持 165 原始口径:ActualTime 秒、SetupTime 小时(列名自带单位)。
+    /// </summary>
+    private async Task ApplyPeriodSequenceActualAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        List<Dictionary<string, object>>? periodDets,
+        List<Dictionary<string, object>>? routings,
+        CancellationToken ct)
+    {
+        if (periodDets is not { Count: > 0 })
+            return;
+
+        // WorkOrdRouting.QtyComplete 作为交叉校验值(仅记录,不用于覆盖)
+        var routingQty = new Dictionary<int, decimal>();
+        if (routings != null)
+        {
+            foreach (var r in routings)
+            {
+                if (!TryGetInt(r, "OP", out var rop) && !TryGetInt(r, "Op", out rop))
+                    continue;
+                routingQty[rop] = GetDecimal(r, "QtyComplete");
+            }
+        }
+
+        var grouped = new Dictionary<int, PsdOpActual>();
+        foreach (var row in periodDets)
+        {
+            if (!TryGetInt(row, "Op", out var op) && !TryGetInt(row, "OP", out op))
+                continue;
+
+            if (!grouped.TryGetValue(op, out var acc))
+            {
+                acc = new PsdOpActual { Op = op };
+                grouped[op] = acc;
+            }
+
+            acc.CompQty += GetDecimal(row, "CompQty");
+            acc.RejectQty += GetDecimal(row, "RejectQty");
+            acc.SetupTimeHour += GetDecimal(row, "SetupTime");
+            acc.ActualTimeSec += GetDecimal(row, "ActualTime");
+            acc.SrcRowCount++;
+            acc.ItemNum ??= GetString(row, "ItemNum");
+
+            if (row.TryGetValue("UpdateTime", out var upd) && upd is DateTime dt
+                && (acc.LastSrcUpdate == null || dt > acc.LastSrcUpdate))
+                acc.LastSrcUpdate = dt;
+        }
+
+        var now = DateTime.Now;
+        var mismatches = new List<string>();
+
+        foreach (var acc in grouped.Values)
+        {
+            var rq = routingQty.TryGetValue(acc.Op, out var q) ? (decimal?)q : null;
+            if (rq.HasValue && rq.Value != acc.CompQty)
+                mismatches.Add($"Op{acc.Op}: psdSum={acc.CompQty} routing={rq.Value}");
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_psd_op_actual (
+                    tenant_id, domain, work_ord, op, item_num,
+                    comp_qty, reject_qty, setup_time_hour, actual_time_sec,
+                    src_row_count, routing_qty, last_src_update, sync_time
+                ) VALUES (
+                    @TenantId, @Domain, @WorkOrd, @Op, @ItemNum,
+                    @CompQty, @RejectQty, @SetupTimeHour, @ActualTimeSec,
+                    @SrcRowCount, @RoutingQty, @LastSrcUpdate, @Now
+                )
+                ON DUPLICATE KEY UPDATE
+                    item_num = VALUES(item_num),
+                    comp_qty = VALUES(comp_qty),
+                    reject_qty = VALUES(reject_qty),
+                    setup_time_hour = VALUES(setup_time_hour),
+                    actual_time_sec = VALUES(actual_time_sec),
+                    src_row_count = VALUES(src_row_count),
+                    routing_qty = VALUES(routing_qty),
+                    last_src_update = VALUES(last_src_update),
+                    sync_time = VALUES(sync_time)
+                """,
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@Domain", domain ?? ""),
+                new SugarParameter("@WorkOrd", workOrd),
+                new SugarParameter("@Op", acc.Op),
+                new SugarParameter("@ItemNum", acc.ItemNum ?? (object)DBNull.Value),
+                new SugarParameter("@CompQty", acc.CompQty),
+                new SugarParameter("@RejectQty", acc.RejectQty),
+                new SugarParameter("@SetupTimeHour", acc.SetupTimeHour),
+                new SugarParameter("@ActualTimeSec", acc.ActualTimeSec),
+                new SugarParameter("@SrcRowCount", acc.SrcRowCount),
+                new SugarParameter("@RoutingQty", rq.HasValue ? rq.Value : (object)DBNull.Value),
+                new SugarParameter("@LastSrcUpdate", acc.LastSrcUpdate ?? (object)DBNull.Value),
+                new SugarParameter("@Now", now));
+        }
+
+        if (mismatches.Count > 0)
+        {
+            // 不阻断落库:差异说明 165 侧口径需人工确认,先留证
+            _logger.LogWarning(
+                "[MdpHotWatch] PSD 实绩与 WorkOrdRouting 不一致 wo={WorkOrd} domain={Domain} {Detail}",
+                workOrd, domain, string.Join("; ", mismatches));
+        }
+
+        _logger.LogInformation(
+            "[MdpHotWatch] applied PSD actual wo={WorkOrd} domain={Domain} ops={Ops} srcRows={Rows}",
+            workOrd, domain, grouped.Count, periodDets.Count);
+    }
+
+    private sealed class PsdOpActual
+    {
+        public int Op { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal CompQty { get; set; }
+        public decimal RejectQty { get; set; }
+        public decimal SetupTimeHour { get; set; }
+        public decimal ActualTimeSec { get; set; }
+        public int SrcRowCount { get; set; }
+        public DateTime? LastSrcUpdate { get; set; }
+    }
+
+    private async Task<long> ResolveLocalWorkOrdTenantAsync(string workOrd, string domain, CancellationToken ct)
+    {
+        var tid = await _db.Ado.SqlQuerySingleAsync<long?>(
+            """
+            SELECT IFNULL(tenant_id, 0)
+            FROM WorkOrdMaster
+            WHERE WorkOrd = @WorkOrd AND IFNULL(Domain, '') = @Domain
+            ORDER BY RecID DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@WorkOrd", workOrd),
+            new SugarParameter("@Domain", domain));
+        return tid ?? 0;
+    }
+
+    private static bool TryGetInt(Dictionary<string, object> row, string key, out int value)
+    {
+        value = 0;
+        if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return false;
+        try
+        {
+            value = Convert.ToInt32(raw);
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private static decimal GetDecimal(Dictionary<string, object> row, string key)
+    {
+        if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return 0m;
+        try { return Convert.ToDecimal(raw); }
+        catch { return 0m; }
+    }
+
+    private static string? GetString(Dictionary<string, object> row, string key)
+    {
+        if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return null;
+        return Convert.ToString(raw);
+    }
+
+    private static string? Trunc(string? s, int max) =>
+        string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]);
+
     private static async Task<bool> ShouldTerminateAsync(
         ISqlSugarClient remote, AdoMdpHotWatch watch, CancellationToken ct)
     {

+ 5 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpOutboxDeadLetterAlertJob.cs

@@ -47,9 +47,11 @@ public class MdpOutboxDeadLetterAlertJob : IJob
         var maxDeadId = await db.Queryable<MdpOutbox>()
             .Where(x => x.Status == 2)
             .MaxAsync(x => (long?)x.Id, stoppingToken) ?? 0L;
-        var newDeadById = maxDeadId > _lastSeenDeadMaxId
+        // SqlSugar 表达式树不能取私有静态字段的值,先落到局部变量再进 lambda
+        var lastSeenDeadMaxId = _lastSeenDeadMaxId;
+        var newDeadById = maxDeadId > lastSeenDeadMaxId
             ? await db.Queryable<MdpOutbox>()
-                .CountAsync(x => x.Status == 2 && x.Id > _lastSeenDeadMaxId, stoppingToken)
+                .CountAsync(x => x.Status == 2 && x.Id > lastSeenDeadMaxId, stoppingToken)
             : 0;
 
         var hasNewDead = newDeadCount > 0 || newDeadById > 0;
@@ -68,7 +70,7 @@ public class MdpOutboxDeadLetterAlertJob : IJob
 
         var stalePending = oldestPendingMinutes > StalePendingThreshold.TotalMinutes;
         _lastScanUtc = DateTime.UtcNow;
-        if (maxDeadId > _lastSeenDeadMaxId)
+        if (maxDeadId > lastSeenDeadMaxId)
             _lastSeenDeadMaxId = maxDeadId;
 
         if (!hasNewDead && !stalePending)

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopStockOptions.cs

@@ -0,0 +1,32 @@
+using Furion.ConfigurableOptions;
+
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// 齐套检查的库存读取来源。配置节 <c>AiDOP:Stock</c>。
+///
+/// Source = Mes(默认,直读 165 InvMaster 实时值)| Local(本库 InvMaster 快照,降级用)。
+/// 本库 InvMaster 是 165 的一次性拷贝、不随交易更新(实测 max UpdateTime 停在 2025-12-31),
+/// 故齐套判定默认必须走 Mes;出问题时改配置回落,不需回滚代码。
+/// </summary>
+[OptionsSettings("AiDOP:Stock")]
+public sealed class AidopStockOptions : IConfigurableOptions
+{
+    /// <summary>Mes | Local。</summary>
+    public string Source { get; set; } = "Mes";
+
+    /// <summary>165 账套(InvMaster.Domain)。实测 165 仅有 8010 一个账套。</summary>
+    public string MesDomain { get; set; } = "8010";
+
+    /// <summary>
+    /// 允许走 165 的租户。仅该租户在本库与 165 一一对应(实测同为 16022 行);
+    /// 其他租户(如 824585161322565)的库存与 165 无关,必须回落本库,否则会读到别人的库存。
+    /// </summary>
+    public long MesTenantId { get; set; } = 797403760988229L;
+
+    /// <summary>单次 IN 查询的物料数上限(防止 SQL 过长 / 参数过多)。</summary>
+    public int BatchSize { get; set; } = 500;
+
+    public bool IsMes =>
+        string.Equals(Source?.Trim(), "Mes", StringComparison.OrdinalIgnoreCase);
+}

+ 164 - 25
server/Plugins/Admin.NET.Plugin.AiDOP/Order/MaterialRequirementCalculator.cs

@@ -1,3 +1,5 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.Order;
@@ -9,17 +11,39 @@ public class MaterialRequirementCalculator : ITransient
 {
     private const int MaxBomDepth = 20;
     private readonly ISqlSugarClient _db;
-
-    public MaterialRequirementCalculator(ISqlSugarClient db)
+    private readonly AidopStockOptions _stockOpt;
+    private readonly Mes165StockReader _mesStock;
+    private readonly ILogger _logger;
+
+    public MaterialRequirementCalculator(
+        ISqlSugarClient db,
+        IOptions<AidopStockOptions> stockOpt,
+        Mes165StockReader mesStock,
+        ILoggerFactory loggerFactory)
     {
         _db = db;
+        _stockOpt = stockOpt.Value;
+        _mesStock = mesStock;
+        _logger = loggerFactory.CreateLogger(nameof(MaterialRequirementCalculator));
     }
 
+    /// <param name="writeOccupy">
+    /// 是否登记库存/在途占用。只有排产才写(占用有明确的版本与清理时机);
+    /// 手动齐套检查是查询语义,多点几次不应把库存占光。
+    /// </param>
+    /// <param name="excludeOccupyWorkOrd">
+    /// 扣减占用时要排除的工单号(通常是本工单)。不排除会把自己上一次的占用当成别人的,
+    /// 导致同一工单反复检查时缺料量不断翻倍。
+    /// </param>
     public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
         OrderWorkOrderGenerationService.OrderHeader order,
         OrderWorkOrderGenerationService.OrderEntryLine entry,
         List<string> warnings,
-        long bangId = 0)
+        long bangId = 0,
+        string? workOrd = null,
+        long morderId = 0,
+        bool writeOccupy = true,
+        string? excludeOccupyWorkOrd = null)
     {
         var itemNum = entry.ItemNumber!.Trim();
         var orderQty = entry.Qty ?? 0;
@@ -50,7 +74,7 @@ public class MaterialRequirementCalculator : ITransient
         if (bomId is null)
         {
             warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
-            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id);
+            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
             return lines;
         }
 
@@ -58,7 +82,7 @@ public class MaterialRequirementCalculator : ITransient
         if (children.Count == 0)
         {
             warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
-            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id);
+            await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
             return lines;
         }
 
@@ -69,7 +93,7 @@ public class MaterialRequirementCalculator : ITransient
                 lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
         }
 
-        await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id);
+        await ApplySupplyAsync(lines, entry.TenantId, bangId, entry.Id, workOrd, morderId, writeOccupy, excludeOccupyWorkOrd);
         return lines;
     }
 
@@ -244,8 +268,17 @@ public class MaterialRequirementCalculator : ITransient
             new SugarParameter("@TenantId", tenantId));
     }
 
-    private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId, long bangId = 0, long entryId = 0)
+    private async Task ApplySupplyAsync(
+        List<ResourceCheckBomLine> lines,
+        long tenantId,
+        long bangId = 0,
+        long entryId = 0,
+        string? workOrd = null,
+        long morderId = 0,
+        bool writeOccupy = true,
+        string? excludeOccupyWorkOrd = null)
     {
+        var workOrdValue = string.IsNullOrWhiteSpace(workOrd) ? "" : workOrd.Trim();
         var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
         var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
 
@@ -254,31 +287,57 @@ public class MaterialRequirementCalculator : ITransient
         var occupiedTransitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
         if (bangId > 0)
         {
+            // 排除指定工单(通常是本工单)自己的占用,否则同一工单重复检查会把自己的占用当别人的
+            var excludeWo = (excludeOccupyWorkOrd ?? "").Trim();
+            var excludeClause = excludeWo.Length == 0 ? "" : " AND IFNULL(morder_mo, '') <> @ExcludeWo";
+
+            var occupyPars = new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BangId", bangId)
+            };
+            if (excludeWo.Length > 0)
+                occupyPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
+
             var occupiedRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
-                """
+                $"""
                 SELECT icitem_number AS ItemNumber, IFNULL(SUM(quantity), 0) AS TotalOccupied
                 FROM ic_item_stockoccupy
-                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
                 GROUP BY icitem_number
                 """,
-                new SugarParameter("@TenantId", tenantId),
-                new SugarParameter("@BangId", bangId));
+                occupyPars);
             foreach (var r in occupiedRows)
                 occupiedStockMap[r.ItemNumber] = r.TotalOccupied;
 
+            var transitPars = new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@BangId", bangId)
+            };
+            if (excludeWo.Length > 0)
+                transitPars.Add(new SugarParameter("@ExcludeWo", excludeWo));
+
             var occupiedTransitRows = await _db.Ado.SqlQueryAsync<OccupyRow>(
-                """
+                $"""
                 SELECT ItemNumber, IFNULL(SUM(OccupyQty), 0) AS TotalOccupied
                 FROM srm_po_occupy
-                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0
+                WHERE tenant_id = @TenantId AND bang_id = @BangId AND IsDeleted = 0{excludeClause}
                 GROUP BY ItemNumber
                 """,
-                new SugarParameter("@TenantId", tenantId),
-                new SugarParameter("@BangId", bangId));
+                transitPars);
             foreach (var r in occupiedTransitRows)
                 occupiedTransitMap[r.ItemNumber] = r.TotalOccupied;
         }
 
+        // 库存基数一次性批量取:MES 权威时读 165 实时值,避免逐行跨库往返
+        var itemNumbers = lines
+            .Where(x => !string.IsNullOrWhiteSpace(x.ItemNumber))
+            .Select(x => x.ItemNumber!.Trim())
+            .Distinct(StringComparer.OrdinalIgnoreCase)
+            .ToList();
+        var rawStockMap = await LoadRawStockMapAsync(itemNumbers, tenantId);
+
         foreach (var line in lines)
         {
             if (string.IsNullOrWhiteSpace(line.ItemNumber))
@@ -286,7 +345,7 @@ public class MaterialRequirementCalculator : ITransient
 
             if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
             {
-                var rawStock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
+                var rawStock = rawStockMap.TryGetValue(line.ItemNumber, out var rs) ? rs : 0m;
                 // 扣减已被同批次其他工单占用的库存
                 var occupied = occupiedStockMap.TryGetValue(line.ItemNumber, out var occ) ? occ : 0m;
                 stock = Math.Max(0m, rawStock - occupied);
@@ -333,8 +392,8 @@ public class MaterialRequirementCalculator : ITransient
             transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingUseQty));
         }
 
-        // 写入占用记录(库存占用和在途占用)
-        if (bangId > 0)
+        // 写入占用记录(库存占用和在途占用)。只有排产写;手动齐套检查是查询语义,只读不写
+        if (bangId > 0 && writeOccupy)
         {
             foreach (var line in lines)
             {
@@ -348,17 +407,18 @@ public class MaterialRequirementCalculator : ITransient
                         await _db.Ado.ExecuteCommandAsync(
                             """
                             INSERT INTO ic_item_stockoccupy (
-                                Id, icitem_number, quantity, morder_mo, bang_id,
+                                Id, icitem_number, quantity, morder_mo, morder_id, bang_id,
                                 orderentry_id, tenant_id, occupy_time, create_time, update_time
                             ) VALUES (
-                                @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
+                                @Id, @ItemNumber, @Qty, @WorkOrd, @MorderId, @BangId,
                                 @EntryId, @TenantId, @Now, @Now, @Now
                             )
                             """,
                             new SugarParameter("@Id", YitIdHelper.NextId()),
                             new SugarParameter("@ItemNumber", line.ItemNumber),
                             new SugarParameter("@Qty", stockUsed),
-                            new SugarParameter("@WorkOrd", ""),
+                            new SugarParameter("@WorkOrd", workOrdValue),
+                            new SugarParameter("@MorderId", morderId),
                             new SugarParameter("@BangId", bangId),
                             new SugarParameter("@EntryId", entryId),
                             new SugarParameter("@TenantId", tenantId),
@@ -371,17 +431,18 @@ public class MaterialRequirementCalculator : ITransient
                         await _db.Ado.ExecuteCommandAsync(
                             """
                             INSERT INTO srm_po_occupy (
-                                Id, ItemNumber, OccupyQty, morder_mo, bang_id,
+                                Id, ItemNumber, OccupyQty, morder_mo, morder_id, bang_id,
                                 polist_id, entry_id, tenant_id, create_time, update_time
                             ) VALUES (
-                                @Id, @ItemNumber, @Qty, @WorkOrd, @BangId,
+                                @Id, @ItemNumber, @Qty, @WorkOrd, @MorderId, @BangId,
                                 @PolistId, @EntryId, @TenantId, @Now, @Now
                             )
                             """,
                             new SugarParameter("@Id", YitIdHelper.NextId()),
                             new SugarParameter("@ItemNumber", line.ItemNumber),
                             new SugarParameter("@Qty", transitUsed),
-                            new SugarParameter("@WorkOrd", ""),
+                            new SugarParameter("@WorkOrd", workOrdValue),
+                            new SugarParameter("@MorderId", morderId),
                             new SugarParameter("@BangId", bangId),
                             new SugarParameter("@PolistId", 0),
                             new SugarParameter("@EntryId", entryId),
@@ -393,8 +454,86 @@ public class MaterialRequirementCalculator : ITransient
         }
     }
 
+    /// <summary>
+    /// 批量取库存基数。
+    ///
+    /// 默认走 165 实时(AiDOP:Stock:Source=Mes):本库 InvMaster 是 165 的一次性快照、
+    /// 不随交易更新,用它判齐套会得出过于乐观的结论。
+    /// 仅配置指定的租户可走 165——本库 InvMaster 含多个租户,其余租户与 165 无对应关系。
+    /// 读 165 失败时回落本库并告警,不让齐套检查整体失败。
+    /// </summary>
+    private async Task<Dictionary<string, decimal>> LoadRawStockMapAsync(
+        List<string> itemNumbers, long tenantId)
+    {
+        if (tenantId <= 0)
+            throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
+        if (itemNumbers.Count == 0)
+            return new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+
+        if (_stockOpt.IsMes && tenantId == _stockOpt.MesTenantId)
+        {
+            try
+            {
+                var mes = await _mesStock.LoadAvailableQtyAsync(
+                    itemNumbers, _stockOpt.MesDomain, _stockOpt.BatchSize);
+                return new Dictionary<string, decimal>(
+                    mes.ToDictionary(kv => kv.Key, kv => RoundQty(kv.Value)),
+                    StringComparer.OrdinalIgnoreCase);
+            }
+            catch (Exception ex)
+            {
+                // 读 165 失败:回落本库快照,结果偏乐观,须在日志里可追溯
+                _logger.LogWarning(ex, "齐套库存读取 165 失败,回落本库 InvMaster 快照(结果可能偏高)");
+            }
+        }
+
+        return await LoadLocalStockMapAsync(itemNumbers, tenantId);
+    }
+
+    /// <summary>本库 InvMaster 批量口径(与 QueryStockQtyAsync 逐条口径一致)。</summary>
+    private async Task<Dictionary<string, decimal>> LoadLocalStockMapAsync(
+        List<string> itemNumbers, long tenantId)
+    {
+        var names = new List<string>(itemNumbers.Count);
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+        for (var i = 0; i < itemNumbers.Count; i++)
+        {
+            names.Add($"@i{i}");
+            pars.Add(new SugarParameter($"@i{i}", itemNumbers[i]));
+        }
+
+        var rows = await _db.Ado.SqlQueryAsync<LocalStockRow>(
+            $"""
+            SELECT ItemNum AS ItemNum,
+                   COALESCE(SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)), 0) AS Qty
+            FROM InvMaster
+            WHERE tenant_id = @TenantId
+              AND ItemNum IN ({string.Join(",", names)})
+            GROUP BY ItemNum
+            """,
+            pars);
+
+        var map = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        foreach (var r in rows)
+        {
+            if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
+            map[r.ItemNum.Trim()] = RoundQty(r.Qty);
+        }
+        return map;
+    }
+
+    private sealed class LocalStockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal Qty { get; set; }
+    }
+
+    // 保留:ApplySupplyAsync 已改为批量预取,本方法供对照/潜在调用方使用
     private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
     {
+        if (tenantId <= 0)
+            throw Oops.Oh("数据异常:无法确定租户,无法查询库存");
+
         var qty = await _db.Ado.GetDecimalAsync(
             """
             SELECT COALESCE(SUM(
@@ -406,7 +545,7 @@ public class MaterialRequirementCalculator : ITransient
             ), 0)
             FROM InvMaster
             WHERE ItemNum = @ItemNum
-              AND (tenant_id = @TenantId OR @TenantId = 0)
+              AND tenant_id = @TenantId
             """,
             new List<SugarParameter>
             {

+ 84 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/Mes165StockReader.cs

@@ -0,0 +1,84 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// 从 165(DOPDEMORQ_SQLSERVER)批量读取实时可用库存。
+///
+/// 只读、只查 InvMaster;连接经 MdpSourceScopeFactory 走 mdp_source 配置,不新开连接串。
+/// 口径与本库旧实现逐字对齐(跨全部 Location 求和、COALESCE(AvailStatusQty, QtyOnHand, 0)、
+/// 不过滤 IsActive),本包只换数据新鲜度,不动口径。
+/// 分块 IN 查询,避免逐物料跨库往返。
+/// </summary>
+public sealed class Mes165StockReader : ITransient
+{
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public Mes165StockReader(MdpSourceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(Mes165StockReader));
+    }
+
+    /// <summary>按物料号批量取可用库存;返回不含未命中的物料(调用方按 0 处理)。</summary>
+    public async Task<Dictionary<string, decimal>> LoadAvailableQtyAsync(
+        IReadOnlyList<string> itemNumbers,
+        string domain,
+        int batchSize = 500,
+        CancellationToken ct = default)
+    {
+        var result = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
+        if (itemNumbers.Count == 0)
+            return result;
+
+        var remote = await _scopeFactory.GetScopeAsync(SourceCode, ct);
+        var size = batchSize <= 0 ? 500 : batchSize;
+
+        for (var offset = 0; offset < itemNumbers.Count; offset += size)
+        {
+            ct.ThrowIfCancellationRequested();
+            var chunk = itemNumbers.Skip(offset).Take(size).ToList();
+
+            var names = new List<string>(chunk.Count);
+            var pars = new List<SugarParameter> { new("@Domain", domain) };
+            for (var i = 0; i < chunk.Count; i++)
+            {
+                names.Add($"@i{i}");
+                pars.Add(new SugarParameter($"@i{i}", chunk[i]));
+            }
+
+            var sql =
+                $"""
+                SELECT ItemNum AS ItemNum,
+                       SUM(COALESCE(AvailStatusQty, QtyOnHand, 0)) AS Qty
+                FROM InvMaster
+                WHERE Domain = @Domain
+                  AND ItemNum IN ({string.Join(",", names)})
+                GROUP BY ItemNum
+                """;
+
+            var rows = await remote.Ado.SqlQueryAsync<StockRow>(sql, pars);
+            foreach (var r in rows)
+            {
+                if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
+                result[r.ItemNum.Trim()] = r.Qty;
+            }
+        }
+
+        _logger.LogInformation(
+            "[Mes165StockReader] domain={Domain} requested={Requested} matched={Matched}",
+            domain, itemNumbers.Count, result.Count);
+
+        return result;
+    }
+
+    private sealed class StockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal Qty { get; set; }
+    }
+}

+ 39 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs

@@ -21,7 +21,9 @@ public class ResourceCheckResultWriter : ITransient
         long? morderId,
         IReadOnlyList<ResourceCheckBomLine> lines,
         string account,
-        DateTime now)
+        DateTime now,
+        long bangId = 0,
+        string checkScope = "REVIEW")
     {
         await InvalidatePreviousAsync(entry.Id, entry.TenantId, workOrd, now);
 
@@ -53,12 +55,14 @@ public class ResourceCheckResultWriter : ITransient
                 Id, sorderid, bill_no, sentry_id, entry_seq, morder_id, morder_no, bom_number,
                 need_qty, need_time, earliest_times, latest_times, order_statr_time, kitting_times,
                 sys_capacity_date, sys_material_date,
+                bangid, check_scope, net_scope,
                 create_by_name, create_time, update_by_name, update_time,
                 tenant_id, factory_id, company_id, IsDeleted
             ) VALUES (
                 @Id, @SorderId, @BillNo, @SentryId, @EntrySeq, @MorderId, @MorderNo, @BomNumber,
                 @NeedQty, @NeedTime, @NeedTime, @NeedTime, @NeedTime, @KittingTime,
                 @NeedTime, @KittingTime,
+                @BangId, @CheckScope, @NetScope,
                 @User, @Now, @User, @Now,
                 @TenantId, @FactoryId, @CompanyId, 0
             )
@@ -74,6 +78,9 @@ public class ResourceCheckResultWriter : ITransient
             new SugarParameter("@NeedQty", needQty),
             new SugarParameter("@NeedTime", needTime),
             new SugarParameter("@KittingTime", kittingTime ?? needTime),
+            new SugarParameter("@BangId", bangId > 0 ? bangId : (object)DBNull.Value),
+            new SugarParameter("@CheckScope", string.IsNullOrWhiteSpace(checkScope) ? "REVIEW" : checkScope.Trim()),
+            new SugarParameter("@NetScope", bangId > 0 ? 1 : 0),
             new SugarParameter("@User", account),
             new SugarParameter("@Now", now),
             new SugarParameter("@TenantId", entry.TenantId),
@@ -159,17 +166,37 @@ public class ResourceCheckResultWriter : ITransient
         };
     }
 
+    /// <summary>
+    /// 让同一工单(或同一订单明细行)的历史齐套结果失效。
+    ///
+    /// 两个要点:
+    /// 1) 以 morder_no 为主条件。sentry_id 只在 &gt; 0 时追加——工单不挂订单明细行时 entry.Id = 0,
+    ///    若按 sentry_id = 0 失效会命中所有同类历史结果,把别的工单的结论一起标删。
+    /// 2) SELECT 与 UPDATE 必须用同一套条件,否则会出现"子表明细已失效、头还是有效"的半失效状态。
+    /// </summary>
     private async Task InvalidatePreviousAsync(long entryId, long tenantId, string morderNo, DateTime now)
     {
+        var mo = (morderNo ?? "").Trim();
+        if (mo.Length == 0 && entryId <= 0)
+            return;
+
+        var conds = new List<string>();
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+        if (mo.Length > 0)
+        {
+            conds.Add("morder_no = @MorderNo");
+            pars.Add(new SugarParameter("@MorderNo", mo));
+        }
+        if (entryId > 0)
+        {
+            conds.Add("sentry_id = @EntryId");
+            pars.Add(new SugarParameter("@EntryId", entryId));
+        }
+
+        var scope = $"tenant_id = @TenantId AND IsDeleted = 0 AND ({string.Join(" OR ", conds)})";
+
         var oldIds = await _db.Ado.SqlQueryAsync<long>(
-            """
-            SELECT Id FROM b_examine_result
-            WHERE tenant_id = @TenantId AND IsDeleted = 0
-              AND (sentry_id = @EntryId OR morder_no = @MorderNo)
-            """,
-            new SugarParameter("@EntryId", entryId),
-            new SugarParameter("@TenantId", tenantId),
-            new SugarParameter("@MorderNo", morderNo));
+            $"SELECT Id FROM b_examine_result WHERE {scope}", pars);
 
         if (oldIds.Count == 0)
             return;
@@ -186,15 +213,10 @@ public class ResourceCheckResultWriter : ITransient
                 new SugarParameter("@Now", now));
         }
 
+        var updPars = new List<SugarParameter>(pars) { new("@Now", now) };
         await _db.Ado.ExecuteCommandAsync(
-            """
-            UPDATE b_examine_result
-            SET IsDeleted = 1, update_time = @Now
-            WHERE sentry_id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
-            """,
-            new SugarParameter("@EntryId", entryId),
-            new SugarParameter("@TenantId", tenantId),
-            new SugarParameter("@Now", now));
+            $"UPDATE b_examine_result SET IsDeleted = 1, update_time = @Now WHERE {scope}",
+            updPars);
     }
 
     private async Task UpdateMesMorderMaterialSituationAsync(

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Production/Entity/PeriodSequenceDet.cs

@@ -61,6 +61,10 @@ public class PeriodSequenceDet
     [SugarColumn(ColumnName = "SetupTime", IsNullable = true)]
     public decimal? SetupTime { get; set; }
 
+    /// <summary>实动工时,单位秒(165 原始单位;与 SetupTime 的小时不同)</summary>
+    [SugarColumn(ColumnName = "ActualTime", IsNullable = true)]
+    public decimal? ActualTime { get; set; }
+
     [SugarColumn(ColumnName = "StdSetupTime", IsNullable = true)]
     public decimal? StdSetupTime { get; set; }
 

+ 7 - 9
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs

@@ -194,8 +194,8 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
 
     private static string BuildListBaseSql(string innerWhere) => $"""
         SELECT
-            IF(p.CompQty > 0, p.CompQty, IF(IFNULL(comPD.CompQty, 0) > 0, comPD.CompQty, 0)) AS CompQty,
-            IF(p.RejectQty > 0, p.RejectQty, IF(IFNULL(comPD.RejectQty, 0) > 0, comPD.RejectQty, 0)) AS RejectQty,
+            GREATEST(IFNULL(p.CompQty, 0), IFNULL(act.comp_qty, 0)) AS CompQty,
+            GREATEST(IFNULL(p.RejectQty, 0), IFNULL(act.reject_qty, 0)) AS RejectQty,
             sm.WorkStartTime AS WorkStartTime,
             sm.DeviceAllocationCount AS DeviceAllocationCount,
             p.RecID AS Id,
@@ -260,13 +260,11 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
         LEFT JOIN ProdLineDetail pd ON p.ItemNum = pd.Part AND p.Line = pd.Line AND p.Op = pd.Op
         LEFT JOIN ScheduleResultOpMaster sm ON sm.PeriodDetRecId = p.RecID
         LEFT JOIN WorkCtrMaster wm ON wm.WorkCtr = sm.WorkCtr 
-        LEFT JOIN (
-            SELECT SUM(CompQty) AS CompQty, SUM(RejectQty) AS RejectQty, PlanDate, `Period` AS UInt2, Op, WorkOrds, ItemNum
-            FROM PeriodSequenceDet
-            WHERE CompQty > 0
-            GROUP BY PlanDate, `Period`, Op, WorkOrds, ItemNum
-        ) comPD ON DATE_FORMAT(p.PlanDate,'%Y-%m-%d') = DATE_FORMAT(comPD.PlanDate,'%Y-%m-%d')
-            AND p.`Period` = comPD.UInt2 AND p.Op = comPD.Op AND p.WorkOrds = comPD.WorkOrds AND p.ItemNum = comPD.ItemNum
+        LEFT JOIN ado_psd_op_actual act
+               ON act.tenant_id = IFNULL(p.tenant_id, 0)
+              AND act.domain = IFNULL(p.`Domain`, '')
+              AND act.work_ord = p.WorkOrds
+              AND act.op = p.Op
         WHERE {innerWhere}
         """;
 

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

@@ -37,6 +37,7 @@ public class Startup : AppStartup
         services.AddConfigurableOptions<S8ActiveFlowWatchOptions>();
         services.AddConfigurableOptions<S8MasterDataOptions>();
         services.AddConfigurableOptions<AidopPickBillOptions>();
+        services.AddConfigurableOptions<AidopStockOptions>();
         // WP9:IPickBillCreator 唯一实现为 Facade(避免 Local/Mes 多实现竞注)
         services.AddTransient<IPickBillCreator, PickBillCreatorFacade>();
         services.AddScoped<AdoS0ExceptionFilter>();

+ 33 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -87,7 +87,25 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         var pars = new List<SugarParameter> { new SugarParameter("@TenantId", tid) };
         // 与库内其它原生 SQL 一致:统一 COLLATE,避免 utf8mb4_general_ci / utf8mb4_0900_ai_ci 混用报错
         const string C = "utf8mb4_general_ci";
-        var conditions = new List<string> { $"a.tenant_id = @TenantId", $"a.Status COLLATE {C} = 'p'" };
+        var conditions = new List<string> { $"a.tenant_id = @TenantId" };
+
+        // 状态:可多选筛选。历史上此处写死 'p',导致已下达工单无法查询,行级「单工单齐套」「MES补偿」对其不可达。
+        var statuses = (input.Status ?? string.Empty)
+            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+            .Select(x => x.ToLowerInvariant())
+            .Where(x => x is "p" or "r" or "w" or "c")
+            .Distinct()
+            .ToList();
+        if (statuses.Count > 0)
+        {
+            var names = new List<string>();
+            for (var i = 0; i < statuses.Count; i++)
+            {
+                names.Add($"@Status{i}");
+                pars.Add(new SugarParameter($"@Status{i}", statuses[i]));
+            }
+            conditions.Add($"LOWER(TRIM(IFNULL(a.Status,''))) COLLATE {C} IN ({string.Join(", ", names)})");
+        }
 
         if (!string.IsNullOrWhiteSpace(input.WorkOrd))
         {
@@ -256,7 +274,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
             var account = _userManager.Account ?? "system";
             var domain = await ResolveWorkOrderDomainAsync(tid, wo);
 
-            var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account);
+            var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "RELEASE");
 
             // WP9 S4:Mes 权威时先校验 165/保管员,再建领料(失败则本库仍为 p),最后写下达字段
             if (_pickBillOpt.IsMes)
@@ -375,20 +393,23 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         var logId = await _runLog.StartAsync("S2_KITTING_CHECK_SINGLE", tid, "WorkOrdMaster", null, wo);
         try
         {
-            var result = await _kittingCheck.CheckSingleAsync(tid, wo, account);
-            if (!result.IsKitted)
-            {
-                await _runLog.FailedAsync(logId, $"缺料行 {result.ShortageLineCount}", result);
-                throw Oops.Oh($"工单 {workord} 齐套检查未通过(缺料行 {result.ShortageLineCount})");
-            }
+            var result = await _kittingCheck.CheckSingleAsync(tid, wo, account, bangId: 0, checkScope: "SINGLE");
 
-            await _runLog.SuccessAsync(logId, "单工单齐套检查通过", result);
-            return new { message = "ok", materialSituation = result.MaterialSituation };
+            // 缺料是正常业务结论,不是接口失败:照常返回,由前端以告警形式呈现并刷新列表
+            await _runLog.SuccessAsync(
+                logId,
+                result.IsKitted ? "单工单齐套检查通过" : $"单工单齐套检查完成(缺料行 {result.ShortageLineCount})",
+                result);
+            return new
+            {
+                message = "ok",
+                materialSituation = result.MaterialSituation,
+                isKitted = result.IsKitted,
+                shortageLineCount = result.ShortageLineCount
+            };
         }
         catch (Exception ex)
         {
-            if (ex.Message.Contains("齐套检查未通过", StringComparison.Ordinal))
-                throw;
             await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
             throw;
         }

+ 44 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderKittingCheckService.cs

@@ -40,7 +40,7 @@ public class WorkOrderKittingCheckService : ITransient
         var result = new KittingCheckResult();
         foreach (var wo in workOrds)
         {
-            var one = await CheckSingleAsync(tenantId, wo, account);
+            var one = await CheckSingleAsync(tenantId, wo, account, bangId: 0, checkScope: "BATCH");
             result.CheckedCount++;
             if (one.IsKitted) result.KittedCount++;
             else result.ShortageCount++;
@@ -52,10 +52,22 @@ public class WorkOrderKittingCheckService : ITransient
     /// 单工单齐套检查:以工单为主,重新 BOM 展开 + 库存计算 → 写入检查结果。
     /// BusinessID>0 时从订单明细行加载数据;BusinessID=0 时从工单/生产工单构造参数。
     /// </summary>
-    public async Task<SingleKittingCheckResult> CheckSingleAsync(long tenantId, string workOrd, string account, long bangId = 0)
+    /// <param name="bangId">
+    /// 排程版本。&gt; 0 表示由排产驱动(净口径 + 写占用);
+    /// = 0 表示手动/下达触发,此时自动复用当前有效排程版本,保持口径一致但只读不写。
+    /// </param>
+    /// <param name="checkScope">触发来源,写入 b_examine_result.check_scope 供事后追溯。</param>
+    public async Task<SingleKittingCheckResult> CheckSingleAsync(
+        long tenantId, string workOrd, string account, long bangId = 0, string checkScope = "SINGLE")
     {
         var wo = workOrd.Trim();
 
+        // 口径统一(C-D1):手动/下达触发时也按净口径算——复用当前有效排程版本的占用账,
+        // 否则每个工单都以为自己能独占全部库存,多工单同时下达必然超发。
+        var isScheduleDriven = bangId > 0;
+        if (!isScheduleDriven)
+            bangId = await ResolveCurrentScheduleBangIdAsync(tenantId) ?? 0;
+
         // 1. 从 WorkOrdMaster + mes_morder 获取工单数据
         var wm = await LoadWorkOrdMasterAsync(tenantId, wo);
         if (wm is null)
@@ -89,11 +101,17 @@ public class WorkOrderKittingCheckService : ITransient
         }
 
         // 3. 重新执行资源检查(BOM 展开 + 库存/在途缺口计算)
+        //    非排产触发:只读不写占用(C-D3),并排除本工单自身占用(C-D2)
         var warnings = new List<string>();
-        var lines = await _calculator.BuildLinesAsync(order, entry, warnings, bangId);
+        var lines = await _calculator.BuildLinesAsync(
+            order, entry, warnings, bangId, wo, wm.MorderId ?? 0,
+            writeOccupy: isScheduleDriven,
+            excludeOccupyWorkOrd: wo);
 
         // 4. 将新结果写入 b_examine_result / b_bom_child_examine 并更新 mes_morder
-        var checkResult = await _writer.WriteAsync(order, entry, wo, wm.MorderId, lines, account, DateTime.Now);
+        var checkResult = await _writer.WriteAsync(
+            order, entry, wo, wm.MorderId, lines, account, DateTime.Now,
+            bangId: bangId, checkScope: checkScope);
 
         // 5. 同步工单物料明细(WorkOrdDetail)
         await _materialDetailSync.EnsureFromResourceCheckAsync(tenantId, wo, account);
@@ -150,6 +168,28 @@ public class WorkOrderKittingCheckService : ITransient
 
     // ──────────────── 数据加载 ────────────────
 
+    /// <summary>
+    /// 取当前有效排程版本 id(= 最近一次成功排产的 aidop_action_run_log.id,与 bang_id 同源)。
+    /// 与 ProductionScheduleGenerationService.ResolveCurrentScheduleBangIdAsync 同逻辑;
+    /// 无成功排产记录时返回 null,口径退化为毛口径(与改动前行为一致,不会更差)。
+    /// </summary>
+    private async Task<long?> ResolveCurrentScheduleBangIdAsync(long tenantId)
+    {
+        var id = await _db.Ado.GetLongAsync(
+            """
+            SELECT id FROM aidop_action_run_log
+            WHERE tenant_id = @TenantId AND action_code = @ActionCode AND status = 'SUCCESS'
+            ORDER BY start_time DESC, id DESC
+            LIMIT 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@ActionCode", "S2_SCHEDULE_GENERATE")
+            });
+        return id > 0 ? id : null;
+    }
+
     private async Task<WorkOrdMasterRow?> LoadWorkOrdMasterAsync(long tenantId, string workOrd)
     {
         var rows = await _db.Ado.SqlQueryAsync<WorkOrdMasterRow>(