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

feat(wp9): unify pick-bill path Mes authority and S5 source switch

Land WP9 S2–S5: IPickBillCreator + Mode=Mes release path, number parity tests, user-facing failure messages with tech diagnostics, S5 list cutover to 165, LEGACY SM mark, and MES buttons demoted to compensation. chore: bump version Web 2.4.279 / server 1.0.309

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX пре 3 дана
родитељ
комит
1c106d1f8e
24 измењених фајлова са 1054 додато и 125 уклоњено
  1. 1 1
      Web/package.json
  2. 1 1
      Web/src/views/aidop/business/workOrderDispatchForm.vue
  3. 30 9
      Web/src/views/aidop/business/workOrderDispatchList.vue
  4. 3 3
      doc/plan/旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md
  5. 12 2
      doc/plan/旧DOP-MES-WMS/对接任务书/_wp9_盘点.md
  6. 69 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_apply.py
  7. 28 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_probe.py
  8. 26 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_std_clear.py
  9. 8 3
      doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md
  10. 5 0
      server/Admin.NET.Application/Configuration/App.json
  11. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  12. 27 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.309.sql
  13. 52 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/PickBillNumberParityTests.cs
  14. 24 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  15. 106 15
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs
  16. 106 5
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionIssueMdpSyncService.cs
  17. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopPickBillOptions.cs
  18. 4 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs
  19. 26 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/IPickBillCreator.cs
  20. 22 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/MesPickBillCreator.cs
  21. 36 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillCreatorFacade.cs
  22. 69 0
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillUserMessages.cs
  23. 343 65
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs
  24. 30 18
      server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderPickBillService.cs

+ 1 - 1
Web/package.json

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

+ 1 - 1
Web/src/views/aidop/business/workOrderDispatchForm.vue

@@ -185,7 +185,7 @@ async function onSave() {
 		if (!msg && typeof e?.message === 'string' && !e.message.includes('status code')) {
 			msg = e.message;
 		}
-		ElMessage.error(msg || '工单下达失败');
+		ElMessage.error(msg || '下达失败!请联系系统管理员。');
 	} finally {
 		saving.value = false;
 	}

+ 30 - 9
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -31,8 +31,8 @@
 		<div class="toolbar">
 			<el-button type="warning" :loading="kitChecking" @click="onKitCheck">物料齐套检查</el-button>
 			<el-button type="success" :loading="mrpGenerating" @click="onMrpGenerate">生成物料需求</el-button>
-			<el-button type="primary" :loading="pickCreating" :disabled="!selectedRows.length" @click="onCreatePickBill">
-				MES下达建领料
+			<el-button type="info" plain :loading="pickCreating" :disabled="!selectedRows.length" @click="onCreatePickBill">
+				MES补偿建领料
 			</el-button>
 		</div>
 
@@ -66,14 +66,14 @@
 					<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
 				</template>
 			</el-table-column>
-			<el-table-column label="操作" width="280" fixed="right" align="center">
+			<el-table-column label="操作" width="300" fixed="right" align="center">
 				<template #default="{ row }">
 					<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-button link type="success" :loading="pickCreatingWo === row.workOrd" @click="onCreatePickBillRow(row)">
-						MES领料
+					<el-button link type="info" :loading="pickCreatingWo === row.workOrd" @click="onCreatePickBillRow(row)">
+						MES补偿
 					</el-button>
 				</template>
 			</el-table-column>
@@ -111,7 +111,7 @@
 <script setup lang="ts" name="aidopBusinessWorkOrderDispatchList">
 import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
 import WorkOrderDispatchForm from './workOrderDispatchForm.vue';
 import PickBillResultDialog from './components/PickBillResultDialog.vue';
@@ -327,7 +327,12 @@ function toastByCode(res: CreatePickBillResult, single: boolean) {
 		if (code === 'OK') ElMessage.success(`MES 领料单已生成:${nbrText}`);
 		else if (code === 'NOOP') ElMessage.info(`无需新建:${nbrText}`);
 		else if (code === 'PARTIAL') ElMessage.warning(res.message || nbrText);
-		else ElMessage.error(res.message || res.items?.[0]?.reason || 'MES 建领料失败');
+		else
+			ElMessage.error(
+				res.message ||
+					res.items?.[0]?.reason ||
+					'建领料失败!请联系系统管理员。'
+			);
 		return;
 	}
 
@@ -349,6 +354,20 @@ async function runCreatePickBill(workOrds: string[], domain: string, single: boo
 	await loadList();
 }
 
+/** D9 过渡 C:MES 入口仅作补偿;日常请用「工单下达」。 */
+async function confirmMesCompensation(scopeText: string) {
+	try {
+		await ElMessageBox.confirm(
+			`${scopeText}\n\n日常请使用「工单下达」(会写入 MES)。\n本操作仅用于补偿重试:若 MES 已有领料单则不会重复建单。\n确认继续?`,
+			'MES 补偿建领料',
+			{ type: 'warning', confirmButtonText: '确认补偿', cancelButtonText: '取消' }
+		);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
 async function onCreatePickBill() {
 	if (!selectedRows.value.length) {
 		ElMessage.warning('请先勾选工单');
@@ -359,11 +378,12 @@ async function onCreatePickBill() {
 		ElMessage.warning('所选行无工单号');
 		return;
 	}
+	if (!(await confirmMesCompensation(`将对已选 ${workOrds.length} 张工单执行 MES 补偿建领料。`))) return;
 	pickCreating.value = true;
 	try {
 		await runCreatePickBill(workOrds, resolveWmsDomain(selectedRows.value[0]), false);
 	} catch (e: any) {
-		ElMessage.error(e?.message || 'MES 建领料失败');
+		ElMessage.error(e?.message || '建领料失败!请联系系统管理员。');
 	} finally {
 		pickCreating.value = false;
 	}
@@ -374,11 +394,12 @@ async function onCreatePickBillRow(row: WorkOrderDispatchRow) {
 		ElMessage.error('工单号为空');
 		return;
 	}
+	if (!(await confirmMesCompensation(`工单 ${row.workOrd} 将执行 MES 补偿建领料。`))) return;
 	pickCreatingWo.value = row.workOrd;
 	try {
 		await runCreatePickBill([row.workOrd], resolveWmsDomain(row), true);
 	} catch (e: any) {
-		ElMessage.error(e?.message || 'MES 建领料失败');
+		ElMessage.error(e?.message || '建领料失败!请联系系统管理员。');
 	} finally {
 		pickCreatingWo.value = null;
 	}

+ 3 - 3
doc/plan/旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md

@@ -4,7 +4,7 @@
 |----|------|
 | 目标 | 消除「工单下达建本库领料单」与「MES 下达建 165 领料单」两条并存路径,确立**唯一权威库**与**唯一取号源**,避免重号、双单、状态口径分裂 |
 | 编写日期 | 2026-08-09 |
-| 状态 | **D7/D8/D9 已定**(2026-08-09:D7-A / D8-A / D9 过渡 C);S1 盘点已出,见 [`_wp9_盘点.md`](./_wp9_盘点.md) |
+| 状态 | **D7/D8/D9 已定**;**S1–S5 已落地**(`Mode=Mes`;S5 列表切 165 + MES 按钮降为补偿) |
 | 前置 | WP2(165 取号)、WP3(Outbox)、WP4 建领料已落地 |
 | 产出 | 决策记录 + 路径开关 + 单一入口 + 号格式一致性测试 + S5 列表切源 + 盘点/对账 SQL |
 | 不做 | 不改 165 结构;不改 `NbrControl` 配置数据;不引入分布式事务;不动退料(`WOD`)/入库(`WOI`)链 |
@@ -209,8 +209,8 @@ SELECT Domain, NbrType, Today, NextValue, MaxValue FROM NbrDayInfo WHERE NbrType
 
 ## 7. 验收清单(交付前逐条勾)
 
-- [ ] D7 / D8 / D9 已决策并回填 WP4 §6、字段矩阵 §14
-- [ ] S1 盘点五个数字已出,重号条数已知
+- [x] D7 / D8 / D9 已决策(见 `_wp9_盘点.md`)
+- [x] S1 盘点五个数字已出,重号条数已知
 - [ ] `IPickBillCreator` 抽象完成,`Mode=Local` 行为与改造前逐字段一致
 - [ ] 号格式一致性测试通过;降级号前缀 `SMX` 与 165 号段不可混淆
 - [ ] `Mode=Mes` 下重复下达幂等,返回原 `Nbr`

+ 12 - 2
doc/plan/旧DOP-MES-WMS/对接任务书/_wp9_盘点.md

@@ -17,8 +17,18 @@
 | **D8** | ✅ **A**:本库已存 SM 保留并打标 `source_system='AIDOP_LEGACY'`,不迁移 |
 | **D9** | ✅ **过渡 C**:MES 按钮作补偿重试;后续再合并为单一「工单下达」 |
 
+## S5 落地记录(2026-08-09)
+
+| 项 | 结果 |
+|----|------|
+| `NbrMaster.source_system` | 已加列;存量 SM 标 `AIDOP_LEGACY` |
+| `mdp_entity` | `S5_PRODUCTION_ISSUE_MASTER`=0;`…_SQLSERVER`=1 |
+| 切源 API | `POST /api/aidopKanban/s5-production-issue-mdp/source-switch`(FULL Replace) |
+| D9 前端 | 「MES下达建领料」→「MES补偿建领料」+ 二次确认 |
+| WP1 确认 | WP1 源表清单已含 `NbrMaster`/`NbrDetail`;S5 列表改读 165 入站→`mdp_std_production_issue`。本库 `NbrMaster` 不再作业务权威,仅保留 LEGACY 历史;完整「165→本库 NbrMaster 只读镜像」仍属 WP1 持续入站能力,不阻塞 S5 列表切源 |
+
 ## 判读
 
 - C1 重号目前未发生(165 为空)。
-- 本库 9 张 SM 属历史/本库路径产物,按 D8-A 保留打标即可。
-- 下一步:WP9 S2 抽象 `IPickBillCreator` + 配置开关(默认仍 `Local` 不改行为),再切 `Mes`。
+- 本库 9 张 SM 属历史/本库路径产物,已按 D8-A 打标 `AIDOP_LEGACY`
+- S5:实体已切 165;`mdp_std_production_issue` 在 165 SM=0 时已清空对齐;部署后可用 `source-switch` 再跑 FULL

+ 69 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_apply.py

@@ -0,0 +1,69 @@
+"""Apply WP9 S5 schema/data changes on aidopdev (equivalent to UpdateScripts/1.0.309.sql)."""
+import json
+from pathlib import Path
+
+import pymysql
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+env = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+conn = pymysql.connect(
+    host=env["MYSQL_HOST"],
+    port=int(env["MYSQL_PORT"]),
+    user=env["MYSQL_USER"],
+    password=env["MYSQL_PASSWORD"],
+    database=env["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=120,
+    autocommit=True,
+)
+cur = conn.cursor()
+
+cur.execute(
+    """
+    SELECT COUNT(1) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='NbrMaster' AND COLUMN_NAME='source_system'
+    """
+)
+if cur.fetchone()[0] == 0:
+    cur.execute(
+        "ALTER TABLE NbrMaster ADD COLUMN source_system VARCHAR(50) NULL DEFAULT NULL "
+        "COMMENT '单据来源:AIDOP_LEGACY=本库历史' AFTER Remark"
+    )
+    print("added source_system")
+else:
+    print("source_system exists")
+
+cur.execute(
+    """
+    UPDATE NbrMaster
+    SET source_system = 'AIDOP_LEGACY'
+    WHERE Type = 'SM' AND IFNULL(source_system, '') IN ('', 'AIDOP')
+    """
+)
+print("legacy_marked", cur.rowcount)
+
+cur.execute(
+    """
+    UPDATE mdp_entity SET status = 0, update_time = NOW()
+    WHERE entity_code = 'S5_PRODUCTION_ISSUE_MASTER' AND status <> 0
+    """
+)
+print("mysql_entity_off", cur.rowcount)
+cur.execute(
+    """
+    UPDATE mdp_entity SET status = 1, update_time = NOW()
+    WHERE entity_code = 'S5_PRODUCTION_ISSUE_MASTER_SQLSERVER' AND status <> 1
+    """
+)
+print("sqlserver_entity_on", cur.rowcount)
+
+cur.execute(
+    "SELECT entity_code, status FROM mdp_entity WHERE entity_code LIKE 'S5_PRODUCTION_ISSUE%' ORDER BY entity_code"
+)
+print("entities", cur.fetchall())
+cur.execute(
+    "SELECT COUNT(1), SUM(CASE WHEN source_system='AIDOP_LEGACY' THEN 1 ELSE 0 END) FROM NbrMaster WHERE Type='SM'"
+)
+print("sm_total_legacy", cur.fetchone())
+conn.close()
+print("OK")

+ 28 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_probe.py

@@ -0,0 +1,28 @@
+import json
+from pathlib import Path
+
+import pymysql
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+env = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+conn = pymysql.connect(
+    host=env["MYSQL_HOST"],
+    port=int(env["MYSQL_PORT"]),
+    user=env["MYSQL_USER"],
+    password=env["MYSQL_PASSWORD"],
+    database=env["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+)
+cur = conn.cursor()
+cur.execute("SHOW COLUMNS FROM NbrMaster")
+cols = [r[0] for r in cur.fetchall()]
+print("has_source_system", "source_system" in cols)
+print("cols_sample", [c for c in cols if "source" in c.lower() or c in ("Remark", "Type", "Nbr", "Ufld1")])
+cur.execute(
+    "SELECT entity_code, status FROM mdp_entity WHERE entity_code LIKE 'S5_PRODUCTION_ISSUE%' ORDER BY entity_code"
+)
+print("entities", cur.fetchall())
+cur.execute("SELECT COUNT(1) FROM NbrMaster WHERE Type='SM'")
+print("sm_count", cur.fetchone()[0])
+conn.close()

+ 26 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp9_s5_std_clear.py

@@ -0,0 +1,26 @@
+"""WP9 S5: 165 当前 SM=0 时,将 mdp_std_production_issue FULL 清空(等同切源后 Replace 结果)。"""
+import json
+from pathlib import Path
+
+import pymysql
+
+cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+env = cfg["mcpServers"]["mysql-aidopdev"]["env"]
+conn = pymysql.connect(
+    host=env["MYSQL_HOST"],
+    port=int(env["MYSQL_PORT"]),
+    user=env["MYSQL_USER"],
+    password=env["MYSQL_PASSWORD"],
+    database=env["MYSQL_DATABASE"],
+    connect_timeout=15,
+    read_timeout=60,
+    autocommit=True,
+)
+cur = conn.cursor()
+cur.execute("SELECT COUNT(1) FROM mdp_std_production_issue")
+before = cur.fetchone()[0]
+cur.execute("DELETE FROM mdp_std_production_issue")
+print("deleted", cur.rowcount, "before", before)
+cur.execute("SELECT COUNT(1) FROM mdp_std_production_issue")
+print("after", cur.fetchone()[0])
+conn.close()

+ 8 - 3
doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md

@@ -1,6 +1,6 @@
 # 执行进度(2026-08-09)
 
-后端版本目标 **1.0.303** / 前端 **2.4.277**(WP10 卡片 0/A/B/C/D + S4b 落地)。
+后端版本目标 **1.0.309** / 前端 **2.4.279**(WP10 全包 + WP9 S1–S5)。
 
 ## 已完成(含本轮收尾)
 
@@ -17,7 +17,12 @@
 | **WP7 B5 / §3.2** | Keeper `Ordinal` 归一 + 硬校验;`WmsBaseDataService` 区间字符集校验;idem_key 去哈希 | `KeeperResolveService` / `WmsBaseDataService` |
 | **WP10 S4a** | Outbox 监控页 + `mdp-outbox` API + 配置类失败不烧重试;`FUNC-S9-016` | `outbox.vue` / `MdpOutboxAdminService` |
 | **WP10 S3** | 结果弹窗组件 + 按 `code` 呈现 | `PickBillResultDialog.vue` |
-| **WP10 S4b** | D12 已定:退避 `1m…120m`、`MaxRetry=6`、死信告警 LogWarning+SysNotice;`next_retry_time`/`last_error_code` | `MdpTargetPushDispatcher` / `MdpOutboxDeadLetterAlertJob` / `1.0.303.sql` |
+| **WP10 S4b** | D12 已定:退避 `1m…120m`、`MaxRetry=6`、死信告警 LogWarning+SysNotice;`next_retry_time`/`last_error_code` | `MdpTargetPushDispatcher` / `MdpOutboxDeadLetterAlertJob` / `1.0.304.sql` |
+| **WP9 S1** | 盘点:本库 SM=9 / 165=0 / 无重号;D7-A/D8-A/D9 过渡 C | [`_wp9_盘点.md`](./_wp9_盘点.md) |
+| **WP9 S2** | `IPickBillCreator` + `PickBillCreatorFacade`;`AiDOP:PickBill:Mode` 默认 Local;降级号 `SMX`+`Remark=本地降级`;去重加 `IsActive=1` | `WorkOrder/IPickBillCreator.cs` 等 |
+| **WP9 S3** | `PickBillNumberParityTests`:本库/165 格式化器同输入同输出;`SMX` 不匹配 165 正则 | 单测已通过 |
+| **WP9 S4** | `Mode=Mes`;`ReleaseWorkOrder` → `IPickBillCreator`;先 165 建单再写本库字段;下达前 165/保管员校验 | `WorkOrderDispatchService` / `App.json` |
+| **WP9 S5** | 切源 165 + LEGACY 打标 + 切源 API;MES 按钮降为补偿+确认;刷新跟启用实体 | `1.0.309.sql` / `ProductionIssueMdpSyncService` / `workOrderDispatchList.vue` |
 
 ## 本轮新增:方案与任务书(文档已先落,代码见上表)
 
@@ -55,7 +60,7 @@
 | 外协 PW(D3)、删周计划(D4)、退料(D6) | 决策未定 |
 | WP5 E2E + 真人 APP | 需现场环境 |
 | WP6 对账 | 依赖 WP5 |
-| 与本库 `WorkOrderPickBillService`(MySQL SM)统一单号 | 本轮保留双路径:本库下达仍走 `NumberRule`;MES 侧走 WP2 `sm`。收敛方案见 [`WP9-领料建单双路径统一.md`](./WP9-领料建单双路径统一.md),**受 D7/D8/D9 阻断**(其 S1 盘点步可先行) |
+| (WP9 S1–S5 本轮代码已齐;切源 FULL Replace 需部署后调 source-switch) | 见 `_wp9_盘点.md` S5 节 |
 | 跨库成败判定与失败兜底 | 复查发现 13 项缺陷并已实测验证:工单 `M500000005` 处于卡死态(本库已下达、165 有领料单但无工单头、回写消息成死信、重放被幂等跳过),`mdp_outbox` 8 行中 4 条死信无人知晓。方案见 [`WP10-跨库成败判定与失败兜底.md`](./WP10-跨库成败判定与失败兜底.md)。**D10/D11/D12 已定(2026-08-09),S0–S4b 均可派发/已落地**(派发前先读其 §11 已知空白,卡片见 §12) |
 
 ## 菜单生效说明

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

@@ -54,6 +54,11 @@
   "S8MasterData": {
     "DepartmentFactoryRefId": 1329900200002 // S0 部门主数据 DepartmentMaster 所在 factory_ref_id(与 S8 运营 factory_id=1 分离);0=由唯一 active D-UNASSIGNED 推导
   },
+  "AiDOP": {
+    "PickBill": {
+      "Mode": "Mes" // WP9 S4:Mes=165 权威建领料;Local=本库降级路径
+    }
+  },
   // 雪花Id
   "SnowId": {
     "WorkerId": 1, // 雪花Id机器码,多服务器时全局唯一

+ 9 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.304</AssemblyVersion>
-    <FileVersion>1.0.304</FileVersion>
-    <Version>1.0.304</Version>
+    <AssemblyVersion>1.0.309</AssemblyVersion>
+    <FileVersion>1.0.309</FileVersion>
+    <Version>1.0.309</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -283,6 +283,12 @@
     <None Update="UpdateScripts\1.0.303.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.304.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.309.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 27 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.309.sql

@@ -0,0 +1,27 @@
+-- 1.0.309:WP9 S5 — S5 生产领料切源到 165 + 本库历史 SM 打 AIDOP_LEGACY
+-- 1) NbrMaster.source_system 列(本库历史标记用)
+-- 2) 存量 Type=SM 标记 AIDOP_LEGACY(D8-A)
+-- 3) 启用 S5_PRODUCTION_ISSUE_MASTER_SQLSERVER,停用本库 S5_PRODUCTION_ISSUE_MASTER
+-- 幂等可重跑。
+
+SET @ddl := (SELECT IF(
+  EXISTS(SELECT 1 FROM information_schema.COLUMNS
+         WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='NbrMaster' AND COLUMN_NAME='source_system'),
+  'SELECT 1',
+  'ALTER TABLE NbrMaster ADD COLUMN source_system VARCHAR(50) NULL DEFAULT NULL COMMENT ''单据来源:AIDOP_LEGACY=本库历史;空/其它由入站或业务写入'' AFTER Remark'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+UPDATE NbrMaster
+SET source_system = 'AIDOP_LEGACY'
+WHERE Type = 'SM'
+  AND IFNULL(source_system, '') IN ('', 'AIDOP');
+
+UPDATE mdp_entity
+SET status = 0, update_time = NOW(),
+    remark = CONCAT(IFNULL(remark, ''), ' | WP9-S5 disabled ', DATE_FORMAT(NOW(), '%Y-%m-%d'))
+WHERE entity_code = 'S5_PRODUCTION_ISSUE_MASTER' AND status <> 0;
+
+UPDATE mdp_entity
+SET status = 1, update_time = NOW(),
+    remark = CONCAT(IFNULL(remark, ''), ' | WP9-S5 enabled ', DATE_FORMAT(NOW(), '%Y-%m-%d'))
+WHERE entity_code = 'S5_PRODUCTION_ISSUE_MASTER_SQLSERVER' AND status <> 1;

+ 52 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/PickBillNumberParityTests.cs

@@ -0,0 +1,52 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Sequence;
+using Admin.NET.Plugin.AiDOP.Supply;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// WP9 S3:本库 NumberRuleFormatter 与 165 NbrSequenceFormatter 对同一规则输出逐字符相同。
+/// </summary>
+public class PickBillNumberParityTests
+{
+    private static readonly Regex Sm165Pattern = new(@"^SM[0-9]{8}[0-9]{3}$", RegexOptions.Compiled);
+
+    [Theory]
+    [InlineData("S", "M", "", "YYYYMMDD", 999, 1, "2026-08-09", "SM20260809001")]
+    [InlineData("S", "M", "", "YYYYMMDD", 999, 42, "2026-08-09", "SM20260809042")]
+    [InlineData("S", "M", "", "YYYYMMDD", 999, 999, "2026-05-10", "SM20260510999")]
+    [InlineData("P", "W", "", "YYYYMMDD", 9999, 1, "2026-05-10", "PW202605100001")]
+    [InlineData("D", "", "", "YYYYMMDD", 99999, 1, "2026-05-10", "D2026051000001")]
+    public void Formatters_SameInputs_ProduceIdenticalNumbers(
+        string pre1, string pre2, string pre3, string dateType, int maxValue, int serial, string nowIso, string expected)
+    {
+        var now = DateTime.Parse(nowIso);
+
+        var local = NumberRuleFormatter.BuildNumber(pre1, pre2, pre3, dateType, true, serial, maxValue, now);
+
+        var norm = NbrSequenceFormatter.NormalizeDateFormat(dateType);
+        var (_, dateStr) = NbrSequenceFormatter.ResolvePeriod(norm, now);
+        var pad = NbrSequenceFormatter.ResolvePadWidth(maxValue);
+        var mes = NbrSequenceFormatter.BuildNumber(pre1, pre2, pre3, dateStr, serial, pad);
+
+        Assert.Equal(expected, local);
+        Assert.Equal(expected, mes);
+        Assert.Equal(local, mes);
+    }
+
+    [Fact]
+    public void SmMaxValue999_PadWidthIsThree()
+    {
+        Assert.Equal(3, NbrSequenceFormatter.ResolvePadWidth(999));
+        Assert.Equal(3, NumberRuleFormatter.ResolveSerialWidth(999));
+    }
+
+    [Fact]
+    public void SmxFallback_DoesNotMatch165Pattern()
+    {
+        var smx = "SMX" + DateTime.Now.ToString("yyyyMMddHHmmss") + "12345";
+        Assert.False(Sm165Pattern.IsMatch(smx));
+        Assert.True(Sm165Pattern.IsMatch("SM20260809001"));
+    }
+}

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -667,6 +667,30 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// WP9 S5:生产领料列表切源到 165(DOPDEMORQ_SQLSERVER)并 FULL Replace 标准层;本库历史 SM 打 AIDOP_LEGACY。
+    /// 破坏性重建 mdp_std_production_issue,运维确认后调用。
+    /// </summary>
+    [HttpPost("s5-production-issue-mdp/source-switch")]
+    public async Task<IActionResult> SwitchS5ProductionIssueSource(
+        [FromQuery] long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        var result = await _productionIssueMdpSyncService.ActivateSqlServerSourceAndSwitchAsync(tenantId, cancellationToken);
+        return Ok(new
+        {
+            ok = true,
+            source = "DOPDEMORQ_SQLSERVER",
+            entity = "S5_PRODUCTION_ISSUE_MASTER_SQLSERVER",
+            pullBatchId = result.PullBatchId,
+            rowsPulled = result.RowsPulled,
+            rowsWrittenStg = result.RowsWrittenStg,
+            transformBatchId = result.TransformBatchId,
+            stdRows = result.StdRows,
+            message = "已切源并全量重建生产领料标准层;本库历史 SM 已标记 AIDOP_LEGACY"
+        });
+    }
+
     /// <summary>
     /// S5 生产入库单 数据中台只读链路手动刷新(DOP 内部扁平:NbrMaster/NbrDetail Type='WOI' → mdp_std_production_receipt)。
     /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std 表;不建 stg/dwd;不碰 S2 写路径、S7、S3/S4、T8;WOI 为 0 行时成功、处理数 0。

+ 106 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs

@@ -96,6 +96,45 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         public string? Message { get; set; }
     }
 
+    /// <summary>WP9 · 单工单建单,映射为 <see cref="PickBillResult"/>(供 IPickBillCreator / Mes 路径)。</summary>
+    public async Task<PickBillResult> CreateForOneAsResultAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        CancellationToken ct = default)
+    {
+        if (string.IsNullOrWhiteSpace(workOrd))
+            throw Oops.Oh("工单号不能为空");
+
+        var resp = await Create(new CreatePickBillInput
+        {
+            WorkOrd = workOrd.Trim(),
+            Domain = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim(),
+            UserAccount = string.IsNullOrWhiteSpace(account) ? "aidop" : account.Trim(),
+            TenantId = tenantId
+        }, ct);
+
+        var item = resp.Items.FirstOrDefault(i =>
+            string.Equals(i.WorkOrd, workOrd.Trim(), StringComparison.OrdinalIgnoreCase))
+            ?? resp.Items.FirstOrDefault();
+
+        if (item == null)
+            throw Oops.Oh(PickBillUserMessages.ForPickCreateFailure(null, resp.Message ?? "没有可建领料的工单", "请刷新后重试。"));
+
+        if (item.Result is "failed" or "skipped")
+            throw Oops.Oh(PickBillUserMessages.ForPickCreateFailure(item.ReasonCode, item.Reason, item.Hint));
+
+        return new PickBillResult
+        {
+            Nbr = item.Nbr ?? "",
+            Created = item.Result == "created",
+            LineCount = item.DetailCount ?? 0,
+            Message = item.Result == "existed" ? "领料单已存在" : (resp.Message ?? "领料单已生成"),
+            Source = "Mes"
+        };
+    }
+
     [DisplayName("下达生成领料单")]
     [HttpPost("create")]
     public async Task<PickBillResponse> Create([FromBody] CreatePickBillInput input, CancellationToken ct = default)
@@ -125,7 +164,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         catch (Exception ex)
         {
             var code = ClassifyMesError(ex);
-            return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
+            return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace, ex, domain);
         }
 
         Dictionary<string, (string Nbr, bool HasDetail)> probe;
@@ -136,7 +175,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         catch (Exception ex)
         {
             var code = ClassifyMesError(ex);
-            return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
+            return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace, ex, domain);
         }
 
         var workOrds = masters.Select(m => m.WorkOrd!).Distinct().ToList();
@@ -159,7 +198,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
             catch (Exception ex)
             {
                 var code = ClassifyMesError(ex);
-                return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
+                return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace, ex, domain);
             }
         }
         else
@@ -367,7 +406,12 @@ public class CreatePickBillService : IDynamicApiController, ITransient
                 var code = ClassifyMesError(ex);
                 // D11-A:MES 不可达整批 fail-fast,不开启本库事务
                 if (code == "MES_UNREACHABLE")
-                    return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace);
+                    return FailAllBatch(masters, code, DescribeMes(code, ex), HintFor(code), trace, ex, domain);
+
+                _logger.LogError(
+                    ex,
+                    "[CreatePickBill] MES write failed code={Code} domain={Domain} trace={Trace} draftCount={Count}",
+                    code, domain, trace, draftMasters.Count);
 
                 foreach (var m in draftMasters)
                 {
@@ -565,6 +609,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
               FROM NbrMaster m
              WHERE m.Domain = @d AND m.Type = @t
                AND ISNULL(m.IsActive, 1) = 1
+               AND ISNULL(m.TransType, '') = ''
                AND m.WorkOrd IN ({inSql})", pars.ToArray());
 
         foreach (var g in rows.GroupBy(r => r.WorkOrd ?? "", StringComparer.OrdinalIgnoreCase))
@@ -891,15 +936,15 @@ public class CreatePickBillService : IDynamicApiController, ITransient
 
     private static string DescribeMes(string code, Exception ex) =>
         code == "MES_UNREACHABLE"
-            ? "165 不可达:" + Trunc(ex.Message, 200)
-            : "165 写入失败:" + Trunc(ex.Message, 200);
+            ? "无法连接 MES 端数据库"
+            : "MES 端写入失败:" + Trunc(ex.Message, 160);
 
     private static string HintFor(string code) => code switch
     {
-        "MES_UNREACHABLE" => "联系运维;稍后重试",
-        "MES_WRITE_FAILED" => "附 SQL 错误信息,联系运维",
-        "SEQ_FAILED" => "联系运维查 NbrDayInfo",
-        _ => ""
+        "MES_UNREACHABLE" => "请联系系统管理员后重试",
+        "MES_WRITE_FAILED" => "请联系系统管理员",
+        "SEQ_FAILED" => "请联系系统管理员检查单号配置",
+        _ => "请联系系统管理员"
     };
 
     private static PickBillResponse FailAll(string message, string code) => new()
@@ -917,19 +962,58 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         }
     };
 
-    private static PickBillResponse FailAllBatch(
-        List<WorkOrdMaster> masters, string code, string reason, string hint, string trace)
+    private PickBillResponse FailAllBatch(
+        List<WorkOrdMaster> masters,
+        string code,
+        string reason,
+        string hint,
+        string trace,
+        Exception? techEx = null,
+        string? domain = null)
     {
+        var userMsg = PickBillUserMessages.ForPickCreateFailure(code, reason, hint);
+        if (techEx != null)
+        {
+            _logger.LogError(
+                techEx,
+                "[CreatePickBill] fail-all code={Code} domain={Domain} trace={Trace} workOrds={WorkOrds} userMsg={UserMsg}",
+                code,
+                domain,
+                trace,
+                string.Join(",", masters.Select(m => m.WorkOrd).Where(x => !string.IsNullOrWhiteSpace(x)).Take(20)),
+                userMsg);
+        }
+        else
+        {
+            _logger.LogWarning(
+                "[CreatePickBill] fail-all code={Code} domain={Domain} trace={Trace} reason={Reason}",
+                code, domain, trace, reason);
+        }
+
         var items = masters.Select(m => new PickBillItemResult
         {
-            WorkOrd = m.WorkOrd ?? "", Result = "failed", ReasonCode = code, Reason = reason, Hint = hint
+            WorkOrd = m.WorkOrd ?? "", Result = "failed", ReasonCode = code, Reason = userMsg, Hint = HintFor(code)
         }).ToList();
-        return BuildResponse(items, 0, 0, false, trace, reason);
+        return BuildResponse(items, 0, 0, false, trace, userMsg);
     }
 
     private static PickBillResponse BuildResponse(
         List<PickBillItemResult> items, int enqueued, int _, bool reconciled, string? trace, string? message = null)
     {
+        // 失败/跳过行统一成面向业务用户的中文原因(保留 ReasonCode 供前端跳转)
+        foreach (var i in items)
+        {
+            if (i.Result is not ("failed" or "skipped")) continue;
+            if (string.IsNullOrWhiteSpace(i.ReasonCode)) continue;
+            if (!string.IsNullOrWhiteSpace(i.Reason) &&
+                (i.Reason.Contains("下达失败", StringComparison.Ordinal) ||
+                 i.Reason.Contains("建领料失败", StringComparison.Ordinal)))
+                continue;
+            i.Reason = PickBillUserMessages.ForPickCreateFailure(i.ReasonCode, i.Reason, i.Hint);
+            if (string.IsNullOrWhiteSpace(i.Hint))
+                i.Hint = HintFor(i.ReasonCode);
+        }
+
         var summary = new PickBillSummary
         {
             Requested = items.Count,
@@ -944,6 +1028,13 @@ public class CreatePickBillService : IDynamicApiController, ITransient
         else if (summary.Created == 0 && summary.Failed == 0) code = "NOOP";
         else code = "FAILED";
 
+        var topMsg = message;
+        if (string.IsNullOrWhiteSpace(topMsg) && code == "FAILED")
+        {
+            var firstFail = items.FirstOrDefault(i => i.Result == "failed");
+            topMsg = firstFail?.Reason;
+        }
+
         return new PickBillResponse
         {
             Ok = summary.Failed == 0 && summary.Created + summary.Existed > 0,
@@ -957,7 +1048,7 @@ public class CreatePickBillService : IDynamicApiController, ITransient
                 Reconciled = reconciled ? true : null
             },
             Trace = trace,
-            Message = message
+            Message = topMsg
         };
     }
 

+ 106 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/ProductionIssueMdpSyncService.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.DataPlatform;
 using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 
@@ -36,13 +37,14 @@ public class ProductionIssueMdpSyncService : ITransient
         _pullDispatcher = pullDispatcher;
     }
 
-    /// <summary>全量:本库 NbrMaster 执行器灌 stg → 标准层(读 stg)。</summary>
+    /// <summary>全量:按当前启用入站实体灌 stg → 标准层(WP9 S5 后默认 165)。</summary>
     public async Task<ProductionIssueMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
     {
         cancellationToken.ThrowIfCancellationRequested();
         await EnsureTablesAsync();
         await EnsureStgTableAsync();
 
+        var active = await ResolveActiveInboundAsync(cancellationToken);
         var now = DateTime.Now;
         var batchId = $"S5_PROD_ISSUE_FULL_{now:yyyyMMddHHmmss}";
         var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
@@ -57,8 +59,19 @@ public class ProductionIssueMdpSyncService : ITransient
                 TaskCode = "S5_PRODUCTION_ISSUE_INBOUND",
                 BatchId = $"{batchId}_PULL"
             };
-            await PopulateStgAsync(InboundEntityCode, pullCtx, cancellationToken);
-            result.HeadRows = await TransformHeadStandardAsync(batchId, now);
+            await PopulateStgAsync(active.EntityCode, pullCtx, cancellationToken);
+            if (active.UseSourceFilter)
+            {
+                result.HeadRows = await MdpStdFullReplace.ReplaceAsync(
+                    _db, "mdp_std_production_issue", 0, extraWhere: null,
+                    insertScopedAsync: () => TransformHeadStandardAsync(batchId, now, active.SourceCode),
+                    cancellationToken);
+            }
+            else
+            {
+                result.HeadRows = await TransformHeadStandardAsync(batchId, now);
+            }
+
             await MarkRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -77,6 +90,7 @@ public class ProductionIssueMdpSyncService : ITransient
         await EnsureTablesAsync();
         await EnsureStgTableAsync();
 
+        var active = await ResolveActiveInboundAsync(cancellationToken);
         var now = DateTime.Now;
         var pullCtx = new MdpPullContext
         {
@@ -85,14 +99,25 @@ public class ProductionIssueMdpSyncService : ITransient
             TaskCode = "S5_PRODUCTION_ISSUE_INBOUND",
             BatchId = $"S5_PROD_ISSUE_IN_{now:yyyyMMddHHmmss}"
         };
-        var pull = await PopulateStgAsync(InboundEntityCode, pullCtx, cancellationToken);
+        var pull = await PopulateStgAsync(active.EntityCode, pullCtx, cancellationToken);
 
         var batchId = $"S5_PROD_ISSUE_STD_{now:yyyyMMddHHmmss}";
         var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
         var result = new ProductionIssueMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
         try
         {
-            result.HeadRows = await TransformHeadStandardAsync(batchId, now);
+            if (active.UseSourceFilter)
+            {
+                result.HeadRows = await MdpStdFullReplace.ReplaceAsync(
+                    _db, "mdp_std_production_issue", tenantId, extraWhere: null,
+                    insertScopedAsync: () => TransformHeadStandardAsync(batchId, now, active.SourceCode),
+                    cancellationToken);
+            }
+            else
+            {
+                result.HeadRows = await TransformHeadStandardAsync(batchId, now);
+            }
+
             await MarkRunSuccessAsync(runLogId, now, result);
         }
         catch (Exception ex)
@@ -112,6 +137,82 @@ public class ProductionIssueMdpSyncService : ITransient
         };
     }
 
+    /// <summary>
+    /// WP9 S5:切到 165 权威源并 FULL Replace 标准层;同时确保本库历史 SM 已打 AIDOP_LEGACY。
+    /// </summary>
+    public async Task<ProductionIssueInboundResult> ActivateSqlServerSourceAndSwitchAsync(
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureNbrMasterSourceSystemColumnAsync();
+        await MarkLocalSmLegacyAsync();
+        await FlipInboundEntityStatusAsync();
+        return await RunSourceSwitchFullAsync(
+            SqlServerSourceCode, SqlServerEntityCode, tenantId, cancellationToken);
+    }
+
+    private async Task<(string EntityCode, string SourceCode, bool UseSourceFilter)> ResolveActiveInboundAsync(
+        CancellationToken cancellationToken)
+    {
+        var sqlOn = await _db.Queryable<MdpEntity>()
+            .Where(x => x.EntityCode == SqlServerEntityCode && x.Status == 1)
+            .AnyAsync(cancellationToken);
+        if (sqlOn)
+            return (SqlServerEntityCode, SqlServerSourceCode, true);
+
+        var mysqlOn = await _db.Queryable<MdpEntity>()
+            .Where(x => x.EntityCode == InboundEntityCode && x.Status == 1)
+            .AnyAsync(cancellationToken);
+        if (mysqlOn)
+            return (InboundEntityCode, "AIDOPDEV_MYSQL", false);
+
+        throw Oops.Oh("生产领料同步失败:未启用任何 S5 入站实体,请执行 WP9 S5 切源或检查 mdp_entity。");
+    }
+
+    private async Task FlipInboundEntityStatusAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_entity
+            SET status = 0, update_time = NOW()
+            WHERE entity_code = @Mysql AND status <> 0
+            """,
+            new SugarParameter("@Mysql", InboundEntityCode));
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_entity
+            SET status = 1, update_time = NOW()
+            WHERE entity_code = @Sql AND status <> 1
+            """,
+            new SugarParameter("@Sql", SqlServerEntityCode));
+    }
+
+    private async Task EnsureNbrMasterSourceSystemColumnAsync()
+    {
+        var exists = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM information_schema.COLUMNS
+            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrMaster' AND COLUMN_NAME = 'source_system'
+            """);
+        if (exists == 0)
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                "ALTER TABLE NbrMaster ADD COLUMN source_system VARCHAR(50) NULL DEFAULT NULL AFTER Remark");
+        }
+    }
+
+    private async Task MarkLocalSmLegacyAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE NbrMaster
+            SET source_system = 'AIDOP_LEGACY'
+            WHERE Type = 'SM'
+              AND IFNULL(source_system, '') IN ('', 'AIDOP')
+            """);
+    }
+
     /// <summary>
     /// 双源切换 FULL Replace(Phase 1):从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 stg,
     /// 成功后在单事务内以「仅当前源」结果 FULL 重建 mdp_std_production_issue(按 tenant 精确隔离),消除旧源独有业务键残留。

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopPickBillOptions.cs

@@ -0,0 +1,17 @@
+using Furion.ConfigurableOptions;
+
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// WP9 · 领料建单权威路径。配置节 <c>AiDOP:PickBill</c>。
+/// Mode = Local(默认,本库 NbrMaster)| Mes(165 权威,走 CreatePickBillService)。
+/// </summary>
+[OptionsSettings("AiDOP:PickBill")]
+public sealed class AidopPickBillOptions : IConfigurableOptions
+{
+    /// <summary>Local | Mes。WP9 S4 起默认 Mes(165 权威)。</summary>
+    public string Mode { get; set; } = "Mes";
+
+    public bool IsMes =>
+        string.Equals(Mode?.Trim(), "Mes", StringComparison.OrdinalIgnoreCase);
+}

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

@@ -12,6 +12,7 @@ using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
 using Admin.NET.Plugin.AiDOP.DataPlatform.HotWatch;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.WorkOrder;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Mvc;
@@ -35,6 +36,9 @@ public class Startup : AppStartup
         services.AddScoped<ChatBIService>();
         services.AddConfigurableOptions<S8ActiveFlowWatchOptions>();
         services.AddConfigurableOptions<S8MasterDataOptions>();
+        services.AddConfigurableOptions<AidopPickBillOptions>();
+        // WP9:IPickBillCreator 唯一实现为 Facade(避免 Local/Mes 多实现竞注)
+        services.AddTransient<IPickBillCreator, PickBillCreatorFacade>();
         services.AddScoped<AdoS0ExceptionFilter>();
         services.AddScoped<AdoS0ResultFilter>();
         services.AddScoped<AdoS0ReferenceChecker>();

+ 26 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/IPickBillCreator.cs

@@ -0,0 +1,26 @@
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// WP9 · 领料建单唯一入口契约。实现方:本库 Local / 165 Mes;由 <see cref="PickBillCreatorFacade"/> 按 Mode 选择。
+/// </summary>
+public interface IPickBillCreator
+{
+    Task<PickBillResult> CreateAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        CancellationToken ct = default);
+}
+
+/// <summary>领料建单结果(本库下达与 Mes 路径共用)。</summary>
+public sealed class PickBillResult
+{
+    public string Nbr { get; set; } = string.Empty;
+    public bool Created { get; set; }
+    public int LineCount { get; set; }
+    public string Message { get; set; } = string.Empty;
+
+    /// <summary>路径标识:Local / Mes / LocalFallback。</summary>
+    public string Source { get; set; } = "Local";
+}

+ 22 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/MesPickBillCreator.cs

@@ -0,0 +1,22 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// WP9 · Mes 路径:薄封装 <see cref="CreatePickBillService"/> 单工单建单。
+/// 不直接注册为 <see cref="IPickBillCreator"/>,由 Facade 按 Mode 选用。
+/// </summary>
+public sealed class MesPickBillCreator : ITransient
+{
+    private readonly CreatePickBillService _create;
+
+    public MesPickBillCreator(CreatePickBillService create) => _create = create;
+
+    public Task<PickBillResult> CreateAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        CancellationToken ct = default)
+        => _create.CreateForOneAsResultAsync(tenantId, domain, workOrd, account, ct);
+}

+ 36 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillCreatorFacade.cs

@@ -0,0 +1,36 @@
+using Microsoft.Extensions.Options;
+
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>
+/// WP9 · 按 <see cref="AidopPickBillOptions.Mode"/> 选择 Local / Mes 建单实现。
+/// 注入点应使用本类型(或 <see cref="IPickBillCreator"/>),勿直接多实现竞注。
+/// </summary>
+public sealed class PickBillCreatorFacade : IPickBillCreator, ITransient
+{
+    private readonly AidopPickBillOptions _opt;
+    private readonly WorkOrderPickBillService _local;
+    private readonly MesPickBillCreator _mes;
+
+    public PickBillCreatorFacade(
+        IOptions<AidopPickBillOptions> opt,
+        WorkOrderPickBillService local,
+        MesPickBillCreator mes)
+    {
+        _opt = opt.Value;
+        _local = local;
+        _mes = mes;
+    }
+
+    public Task<PickBillResult> CreateAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        CancellationToken ct = default)
+    {
+        if (_opt.IsMes)
+            return _mes.CreateAsync(tenantId, domain, workOrd, account, ct);
+        return _local.CreateAsync(tenantId, domain, workOrd, account, ct);
+    }
+}

+ 69 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/PickBillUserMessages.cs

@@ -0,0 +1,69 @@
+namespace Admin.NET.Plugin.AiDOP.WorkOrder;
+
+/// <summary>领料建单 / 工单下达面向业务用户的失败提示(不暴露内部错误码作主文案)。</summary>
+public static class PickBillUserMessages
+{
+    public static string ForReleaseFailure(string? reasonCode, string? reason = null, string? hint = null, IEnumerable<string>? items = null)
+    {
+        var involved = FormatItems(items);
+        var msg = (reasonCode ?? "").Trim().ToUpperInvariant() switch
+        {
+            "MES_UNREACHABLE" =>
+                "下达失败!因无法连接 MES 端数据库,请联系系统管理员后重试。",
+            "MES_WRITE_FAILED" =>
+                "下达失败!MES 端写入领料单失败,请联系系统管理员。",
+            "SEQ_FAILED" =>
+                "下达失败!MES 端领料单号取号失败,请联系系统管理员。",
+            "NO_KEEPER" =>
+                "下达失败!部分物料未配置保管员或库位,请先在仓储基础数据中补齐后重试。" + involved,
+            "KEEPER_RULE_UNSAFE" =>
+                "下达失败!物料号或保管员职责区间含不支持的字符,请联系系统管理员处理。" + involved,
+            "NO_DETAIL" =>
+                "下达失败!工单无物料明细,请先同步或维护物料后重试。",
+            "ITEM_INVALID" =>
+                "下达失败!工单物料主数据缺失或无效,请先补齐物料主数据后重试。",
+            "WO_STATE_INVALID" =>
+                "下达失败!当前工单状态不允许下达,请刷新列表后重试。",
+            "ORPHAN_DETECTED" =>
+                "下达失败!MES 端存在异常领料单数据,请联系系统管理员处理。",
+            _ => BuildFallback(reason, hint)
+        };
+        return msg;
+    }
+
+    /// <summary>MES 补偿按钮等非「下达」入口,措辞改为「建领料失败」。</summary>
+    public static string ForPickCreateFailure(string? reasonCode, string? reason = null, string? hint = null, IEnumerable<string>? items = null)
+        => ForReleaseFailure(reasonCode, reason, hint, items)
+            .Replace("下达失败!", "建领料失败!", StringComparison.Ordinal);
+
+    private static string BuildFallback(string? reason, string? hint)
+    {
+        if (!string.IsNullOrWhiteSpace(reason) && reason.Contains("下达失败", StringComparison.Ordinal))
+            return reason.Trim();
+        if (!string.IsNullOrWhiteSpace(reason) && LooksLikeConnectionIssue(reason))
+            return "下达失败!因无法连接 MES 端数据库,请联系系统管理员后重试。";
+
+        var detail = string.IsNullOrWhiteSpace(reason) ? "原因未知" : Trunc(reason.Trim(), 120);
+        var action = string.IsNullOrWhiteSpace(hint) ? "请联系系统管理员。" : hint.Trim();
+        return $"下达失败!{detail}。{action}";
+    }
+
+    private static bool LooksLikeConnectionIssue(string text)
+    {
+        return text.Contains("不可达", StringComparison.OrdinalIgnoreCase)
+               || text.Contains("timeout", StringComparison.OrdinalIgnoreCase)
+               || text.Contains("连接", StringComparison.Ordinal)
+               || text.Contains("network", StringComparison.OrdinalIgnoreCase)
+               || text.Contains("MES_UNREACHABLE", StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static string FormatItems(IEnumerable<string>? items)
+    {
+        if (items == null) return "";
+        var list = items.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Take(15).ToList();
+        return list.Count == 0 ? "" : " 涉及物料:" + string.Join("、", list) + (list.Count >= 15 ? "…" : "");
+    }
+
+    private static string Trunc(string s, int max) =>
+        s.Length <= max ? s : s[..max] + "…";
+}

+ 343 - 65
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderDispatchService.cs

@@ -1,5 +1,9 @@
 using System.Globalization;
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 
 namespace Admin.NET.Plugin.AiDOP.WorkOrder;
 
@@ -16,21 +20,36 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly WorkOrderKittingCheckService _kittingCheck;
-    private readonly WorkOrderPickBillService _pickBill;
+    private readonly IPickBillCreator _pickBillCreator;
+    private readonly AidopPickBillOptions _pickBillOpt;
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly KeeperResolveService _keeper;
     private readonly AidopActionRunLogWriter _runLog;
+    private readonly ILogger<WorkOrderDispatchService> _logger;
+
+    /// <summary>单次下达请求内的诊断上下文(写入 run_log.detail_json,不回传前端)。</summary>
+    private ReleaseDiagContext? _diag;
 
     public WorkOrderDispatchService(
         ISqlSugarClient db,
         UserManager userManager,
         WorkOrderKittingCheckService kittingCheck,
-        WorkOrderPickBillService pickBill,
-        AidopActionRunLogWriter runLog)
+        IPickBillCreator pickBillCreator,
+        IOptions<AidopPickBillOptions> pickBillOpt,
+        MdpSourceScopeFactory scopeFactory,
+        KeeperResolveService keeper,
+        AidopActionRunLogWriter runLog,
+        ILogger<WorkOrderDispatchService> logger)
     {
         _db = db;
         _userManager = userManager;
         _kittingCheck = kittingCheck;
-        _pickBill = pickBill;
+        _pickBillCreator = pickBillCreator;
+        _pickBillOpt = pickBillOpt.Value;
+        _scopeFactory = scopeFactory;
+        _keeper = keeper;
         _runLog = runLog;
+        _logger = logger;
     }
 
     private long ResolveTenantId(long? requestTenantId) =>
@@ -186,7 +205,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
             // ── 下达前置校验 ──
             // 1. 生产批号必须有值
             if (string.IsNullOrWhiteSpace(input.LotSerial))
-                throw Oops.Oh("工单下达失败:生产批号不能为空,请先获取或输入生产批号");
+                throw Oops.Oh("下达失败!生产批号不能为空,请先获取或输入生产批号");
 
             // 2. 工单物料明细至少一条
             var detailCount = await _db.Ado.GetIntAsync(
@@ -197,7 +216,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 new SugarParameter("@TenantId", tid),
                 new SugarParameter("@WorkOrd", wo));
             if (detailCount == 0)
-                throw Oops.Oh("工单下达失败:工单物料明细为空,请先同步物料明细");
+                throw Oops.Oh("下达失败!工单物料明细为空,请先同步或维护物料后重试。");
 
             // 3. 工单工序明细至少一条
             var routingCount = await _db.Ado.GetIntAsync(
@@ -208,7 +227,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 new SugarParameter("@TenantId", tid),
                 new SugarParameter("@WorkOrd", wo));
             if (routingCount == 0)
-                throw Oops.Oh("工单下达失败:工单工序明细为空,请先同步工艺路线");
+                throw Oops.Oh("下达失败!工单工序明细为空,请先同步工艺路线后重试。");
 
             if (!string.IsNullOrWhiteSpace(input.LotSerial))
             {
@@ -224,7 +243,7 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                     new SugarParameter("@LotSerial", ls),
                     new SugarParameter("@WorkOrd", wo));
                 if (dup > 0)
-                    throw Oops.Oh("生产批号已被本租户下其他工单占用,请重新获取或修改");
+                    throw Oops.Oh("下达失败!生产批号已被其他工单占用,请重新获取或修改");
             }
 
             DateTime? ordDate = string.IsNullOrWhiteSpace(input.OrdDate)
@@ -232,68 +251,47 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 : DateTime.Parse(input.OrdDate.Trim(), CultureInfo.InvariantCulture).Date;
             var releaseDate = DateTime.Now;
             var account = _userManager.Account ?? "system";
+            var domain = await ResolveWorkOrderDomainAsync(tid, wo);
 
             var kitting = await _kittingCheck.CheckSingleAsync(tid, wo, account);
 
-            WorkOrderPickBillService.PickBillResult pickResult;
-            await _db.Ado.BeginTranAsync();
-            try
-            {
-                pickResult = await _pickBill.CreateForWorkOrderAsync(tid, wo, account);
+            // WP9 S4:Mes 权威时先校验 165/保管员,再建领料(失败则本库仍为 p),最后写下达字段
+            if (_pickBillOpt.IsMes)
+                await EnsureMesPickPrereqsAsync(tid, domain, wo);
 
-                var updatePars = new List<SugarParameter>
+            PickBillResult pickResult;
+            if (_pickBillOpt.IsMes)
+            {
+                pickResult = await CreatePickBillForReleaseAsync(tid, domain, wo, account);
+                await _db.Ado.BeginTranAsync();
+                try
                 {
-                    new SugarParameter("@Status",      "r"),
-                    new SugarParameter("@OrdDate",     ordDate ?? (object)DBNull.Value),
-                    new SugarParameter("@LotSerial",   string.IsNullOrWhiteSpace(input.LotSerial) ? (object)DBNull.Value : input.LotSerial.Trim()),
-                    new SugarParameter("@ReleaseDate", releaseDate),
-                    new SugarParameter("@UpdateUser",  account),
-                    new SugarParameter("@UpdateTime",  releaseDate),
-                    new SugarParameter("@WorkOrd",     wo),
-                    new SugarParameter("@TenantId",   tid)
-                };
-
-                var affected = await _db.Ado.ExecuteCommandAsync(
-                    """
-                    UPDATE WorkOrdMaster
-                    SET Status      = @Status,
-                        OrdDate     = @OrdDate,
-                        DueDate     = CASE
-                            WHEN OrdDate IS NOT NULL AND @OrdDate IS NOT NULL AND DueDate IS NOT NULL THEN
-                                DATE_ADD(DATE(DueDate), INTERVAL DATEDIFF(DATE(@OrdDate), DATE(OrdDate)) DAY)
-                            ELSE DueDate
-                        END,
-                        LotSerial   = @LotSerial,
-                        ReleaseDate = @ReleaseDate,
-                        Eff         = @ReleaseDate,
-                        UpdateUser  = @UpdateUser,
-                        UpdateTime  = @UpdateTime,
-                        Batch       = SUBSTRING(@WorkOrd, 2)
-                    WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
-                    """,
-                    updatePars);
-
-                if (affected == 0)
-                    throw Oops.Oh("工单下达失败:未更新到对应工单,请确认工单号、租户及状态仍为初始(p)");
-
-                await _db.Ado.ExecuteCommandAsync(
-                    """
-                    UPDATE WorkOrdRouting
-                    SET Status = 'r', UpdateUser = @User, UpdateTime = @Now
-                    WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
-                      AND IFNULL(MilestoneOp, 0) = 1 AND IFNULL(IsActive, 0) = 1
-                    """,
-                    new SugarParameter("@User", account),
-                    new SugarParameter("@Now", releaseDate),
-                    new SugarParameter("@TenantId", tid),
-                    new SugarParameter("@WorkOrd", wo));
-
-                await _db.Ado.CommitTranAsync();
+                    await ApplyReleaseLocalAsync(tid, wo, account, ordDate, input.LotSerial, releaseDate, requireStatusP: false);
+                    await _db.Ado.CommitTranAsync();
+                }
+                catch (Exception ex)
+                {
+                    await _db.Ado.RollbackTranAsync();
+                    throw ThrowReleaseFail(
+                        PickBillUserMessages.ForReleaseFailure(null, "本库下达字段更新失败:" + TruncMsg(ex.Message), "请联系系统管理员。"),
+                        "LOCAL_UPDATE_FAILED", tid, domain, wo, ex);
+                }
             }
-            catch
+            else
             {
-                await _db.Ado.RollbackTranAsync();
-                throw;
+                await _db.Ado.BeginTranAsync();
+                try
+                {
+                    pickResult = await CreatePickBillForReleaseAsync(tid, domain, wo, account);
+                    await ApplyReleaseLocalAsync(tid, wo, account, ordDate, input.LotSerial, releaseDate, requireStatusP: true);
+                    await _db.Ado.CommitTranAsync();
+                }
+                catch (Exception ex)
+                {
+                    await _db.Ado.RollbackTranAsync();
+                    CaptureDiag("LOCAL_TRAN_FAILED", tid, domain, wo, ex, ex.Message);
+                    throw;
+                }
             }
 
             var response = new
@@ -302,14 +300,30 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
                 materialSituation = kitting.MaterialSituation,
                 pickBillNbr = pickResult.Nbr,
                 pickBillCreated = pickResult.Created,
-                pickBillLineCount = pickResult.LineCount
+                pickBillLineCount = pickResult.LineCount,
+                pickBillSource = pickResult.Source,
+                pickBillMode = _pickBillOpt.Mode
             };
             await _runLog.SuccessAsync(logId, "工单下达成功", response);
             return response;
         }
         catch (Exception ex)
         {
-            await _runLog.FailedAsync(logId, ex.Message, new { workOrd = wo, tenantId = tid });
+            if (_diag == null)
+                CaptureDiag(null, tid, null, wo, ex, ex.Message);
+
+            await _runLog.FailedAsync(logId, ex.Message, new
+            {
+                workOrd = wo,
+                tenantId = tid,
+                mode = _pickBillOpt.Mode,
+                reasonCode = _diag?.ReasonCode,
+                domain = _diag?.Domain,
+                userMessage = ex.Message,
+                exceptionType = _diag?.ExceptionType ?? ex.GetType().FullName,
+                techDetail = _diag?.TechDetail ?? TruncMsg(ex.ToString(), 4000),
+                loggedAt = DateTime.Now.ToString("O")
+            });
             throw;
         }
     }
@@ -377,6 +391,270 @@ public class WorkOrderDispatchService : IDynamicApiController, ITransient
         }
     }
 
+    private async Task<string> ResolveWorkOrderDomainAsync(long tenantId, string workOrd)
+    {
+        var domain = await _db.Ado.GetStringAsync(
+            """
+            SELECT TRIM(IFNULL(`Domain`, '')) FROM WorkOrdMaster
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@WorkOrd", workOrd)
+            });
+        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
+    }
+
+    private async Task<PickBillResult> CreatePickBillForReleaseAsync(
+        long tenantId, string domain, string workOrd, string account)
+    {
+        try
+        {
+            return await _pickBillCreator.CreateAsync(tenantId, domain, workOrd, account);
+        }
+        catch (Exception ex)
+        {
+            var raw = ex.Message ?? "";
+            var reasonCode = GuessReasonCode(raw);
+            if (raw.Contains("下达失败", StringComparison.Ordinal) || raw.Contains("建领料失败", StringComparison.Ordinal))
+            {
+                throw ThrowReleaseFail(
+                    raw.Replace("建领料失败!", "下达失败!", StringComparison.Ordinal),
+                    reasonCode, tenantId, domain, workOrd, ex);
+            }
+
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure(reasonCode, raw, "请联系系统管理员。"),
+                reasonCode ?? "PICK_CREATE_FAILED", tenantId, domain, workOrd, ex);
+        }
+    }
+
+    /// <summary>WP9 S4:下达前校验 165 可达 + 保管员可解析。</summary>
+    private async Task EnsureMesPickPrereqsAsync(long tenantId, string domain, string workOrd)
+    {
+        try
+        {
+            await _scopeFactory.GetScopeAsync(CreatePickBillService.TargetSource);
+        }
+        catch (Exception ex)
+        {
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE"),
+                "MES_UNREACHABLE", tenantId, domain, workOrd, ex);
+        }
+
+        var itemNums = await _db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT DISTINCT TRIM(ItemNum) AS ItemNum
+            FROM WorkOrdDetail
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+              AND IFNULL(IsActive, 0) = 1 AND IFNULL(ItemNum, '') <> ''
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        if (itemNums.Count == 0)
+            throw ThrowReleaseFail(
+                "下达失败!工单物料明细为空,请先同步或维护物料后重试。",
+                "NO_DETAIL", tenantId, domain, workOrd);
+
+        KeeperResolveService.KeeperResolveResult keeper;
+        try
+        {
+            keeper = await _keeper.ResolveByItemsAsync(domain, itemNums);
+        }
+        catch (Exception ex)
+        {
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure("MES_UNREACHABLE"),
+                "MES_UNREACHABLE", tenantId, domain, workOrd, ex);
+        }
+
+        if (keeper.UnsafeItems.Count > 0)
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure("KEEPER_RULE_UNSAFE", items: keeper.UnsafeItems),
+                "KEEPER_RULE_UNSAFE", tenantId, domain, workOrd,
+                techNote: "unsafeItems=" + string.Join(",", keeper.UnsafeItems.Take(30)));
+
+        if (keeper.EmptyLocationItems.Count > 0)
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure("NO_KEEPER", items: keeper.EmptyLocationItems),
+                "NO_KEEPER", tenantId, domain, workOrd,
+                techNote: "emptyLocationItems=" + string.Join(",", keeper.EmptyLocationItems.Take(30)));
+
+        if (keeper.MissingItems.Count > 0)
+            throw ThrowReleaseFail(
+                PickBillUserMessages.ForReleaseFailure("NO_KEEPER", items: keeper.MissingItems),
+                "NO_KEEPER", tenantId, domain, workOrd,
+                techNote: "missingItems=" + string.Join(",", keeper.MissingItems.Take(30)));
+    }
+
+    private Exception ThrowReleaseFail(
+        string userMessage,
+        string? reasonCode,
+        long tenantId,
+        string? domain,
+        string workOrd,
+        Exception? techEx = null,
+        string? techNote = null)
+    {
+        CaptureDiag(reasonCode, tenantId, domain, workOrd, techEx, userMessage, techNote);
+        return Oops.Oh(userMessage);
+    }
+
+    private void CaptureDiag(
+        string? reasonCode,
+        long tenantId,
+        string? domain,
+        string workOrd,
+        Exception? techEx,
+        string? userMessage,
+        string? techNote = null)
+    {
+        var tech = techEx != null
+            ? TruncMsg(techEx.ToString(), 4000)
+            : TruncMsg(techNote, 2000);
+
+        _diag = new ReleaseDiagContext
+        {
+            ReasonCode = reasonCode,
+            Domain = domain,
+            ExceptionType = techEx?.GetType().FullName,
+            TechDetail = tech,
+            UserMessage = userMessage
+        };
+
+        if (techEx != null)
+        {
+            _logger.LogError(
+                techEx,
+                "[WorkOrderRelease] reasonCode={ReasonCode} workOrd={WorkOrd} tenantId={TenantId} domain={Domain} mode={Mode} userMsg={UserMsg}",
+                reasonCode, workOrd, tenantId, domain, _pickBillOpt.Mode, userMessage);
+        }
+        else
+        {
+            _logger.LogWarning(
+                "[WorkOrderRelease] reasonCode={ReasonCode} workOrd={WorkOrd} tenantId={TenantId} domain={Domain} mode={Mode} userMsg={UserMsg} note={Note}",
+                reasonCode, workOrd, tenantId, domain, _pickBillOpt.Mode, userMessage, techNote);
+        }
+    }
+
+    private static string? GuessReasonCode(string message)
+    {
+        if (message.Contains("无法连接 MES", StringComparison.Ordinal) ||
+            message.Contains("MES_UNREACHABLE", StringComparison.OrdinalIgnoreCase))
+            return "MES_UNREACHABLE";
+        if (message.Contains("保管员", StringComparison.Ordinal) ||
+            message.Contains("NO_KEEPER", StringComparison.OrdinalIgnoreCase))
+            return "NO_KEEPER";
+        if (message.Contains("KEEPER_RULE_UNSAFE", StringComparison.OrdinalIgnoreCase) ||
+            message.Contains("不支持的字符", StringComparison.Ordinal))
+            return "KEEPER_RULE_UNSAFE";
+        if (message.Contains("取号", StringComparison.Ordinal) ||
+            message.Contains("SEQ_FAILED", StringComparison.OrdinalIgnoreCase))
+            return "SEQ_FAILED";
+        return null;
+    }
+
+    private static string TruncMsg(string? s, int max = 120)
+    {
+        s ??= "";
+        return s.Length <= max ? s : s[..max] + "…";
+    }
+
+    private sealed class ReleaseDiagContext
+    {
+        public string? ReasonCode { get; set; }
+        public string? Domain { get; set; }
+        public string? ExceptionType { get; set; }
+        public string? TechDetail { get; set; }
+        public string? UserMessage { get; set; }
+    }
+
+    /// <summary>
+    /// 本库下达字段:Status/批号/日期/里程碑工序/mes_morder。
+    /// Mes 路径下建单服务可能已将 Status 置 r,故 <paramref name="requireStatusP"/> 为 false。
+    /// </summary>
+    private async Task ApplyReleaseLocalAsync(
+        long tenantId,
+        string workOrd,
+        string account,
+        DateTime? ordDate,
+        string? lotSerial,
+        DateTime releaseDate,
+        bool requireStatusP)
+    {
+        var updatePars = new List<SugarParameter>
+        {
+            new("@Status", "r"),
+            new("@OrdDate", ordDate ?? (object)DBNull.Value),
+            new("@LotSerial", string.IsNullOrWhiteSpace(lotSerial) ? (object)DBNull.Value : lotSerial.Trim()),
+            new("@ReleaseDate", releaseDate),
+            new("@UpdateUser", account),
+            new("@UpdateTime", releaseDate),
+            new("@WorkOrd", workOrd),
+            new("@TenantId", tenantId)
+        };
+
+        var statusFilter = requireStatusP
+            ? " AND LOWER(TRIM(IFNULL(Status,''))) = 'p'"
+            : "";
+
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $"""
+            UPDATE WorkOrdMaster
+            SET Status      = @Status,
+                OrdDate     = @OrdDate,
+                DueDate     = CASE
+                    WHEN OrdDate IS NOT NULL AND @OrdDate IS NOT NULL AND DueDate IS NOT NULL THEN
+                        DATE_ADD(DATE(DueDate), INTERVAL DATEDIFF(DATE(@OrdDate), DATE(OrdDate)) DAY)
+                    ELSE DueDate
+                END,
+                LotSerial   = @LotSerial,
+                ReleaseDate = @ReleaseDate,
+                Eff         = @ReleaseDate,
+                UpdateUser  = @UpdateUser,
+                UpdateTime  = @UpdateTime,
+                Batch       = SUBSTRING(@WorkOrd, 2)
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+            {statusFilter}
+            """,
+            updatePars);
+
+        if (affected == 0)
+            throw Oops.Oh("下达失败!未更新到对应工单,请确认工单仍为初始状态后重试。");
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE WorkOrdRouting
+            SET Status = 'r', UpdateUser = @User, UpdateTime = @Now
+            WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
+              AND IFNULL(MilestoneOp, 0) = 1 AND IFNULL(IsActive, 0) = 1
+            """,
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", releaseDate),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mes_morder
+            SET morder_state = '下达',
+                MaterialSituation = CASE
+                    WHEN MaterialSituation = '缺料' THEN '未备料'
+                    ELSE IFNULL(MaterialSituation, '齐套')
+                END,
+                update_by_name = @User,
+                update_time = @Now
+            WHERE tenant_id = @TenantId AND morder_no = @WorkOrd AND IsDeleted = 0
+            """,
+            new SugarParameter("@User", account),
+            new SugarParameter("@Now", releaseDate),
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@WorkOrd", workOrd));
+    }
+
     // ──────────────── 内部查询结果映射类 ────────────────
 
     private sealed class WorkOrderDispatchRow

+ 30 - 18
server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderPickBillService.cs

@@ -3,7 +3,9 @@ using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.WorkOrder;
 
-/// <summary>工单下达领料单生成(NbrMaster / NbrDetail,Type=SM)。</summary>
+/// <summary>
+/// 本库领料单生成(NbrMaster / NbrDetail,Type=SM)。WP9 Local 路径 / LocalPickBillCreator。
+/// </summary>
 public class WorkOrderPickBillService : ITransient
 {
     private readonly ISqlSugarClient _db;
@@ -20,12 +22,21 @@ public class WorkOrderPickBillService : ITransient
         _detailSync = detailSync;
     }
 
-    public async Task<PickBillResult> CreateForWorkOrderAsync(long tenantId, string workOrd, string account)
+    /// <summary>兼容旧调用;等价于 CreateAsync(domain 由工单推导)。</summary>
+    public Task<PickBillResult> CreateForWorkOrderAsync(long tenantId, string workOrd, string account)
+        => CreateAsync(tenantId, domain: "", workOrd, account);
+
+    public async Task<PickBillResult> CreateAsync(
+        long tenantId,
+        string domain,
+        string workOrd,
+        string account,
+        CancellationToken ct = default)
     {
         workOrd = workOrd.Trim();
         var existing = await FindExistingPickBillAsync(tenantId, workOrd);
         if (!string.IsNullOrWhiteSpace(existing))
-            return new PickBillResult { Nbr = existing, Created = false, Message = "领料单已存在" };
+            return new PickBillResult { Nbr = existing, Created = false, Message = "领料单已存在", Source = "Local" };
 
         var wo = await LoadWorkOrderAsync(tenantId, workOrd)
             ?? throw Oops.Oh($"工单 {workOrd} 不存在");
@@ -36,21 +47,28 @@ public class WorkOrderPickBillService : ITransient
         if (details.Count == 0)
             throw Oops.Oh($"工单 {workOrd} 无物料明细,请先完成订单评审资源检查");
 
-        var domain = string.IsNullOrWhiteSpace(wo.Domain) ? tenantId.ToString() : wo.Domain.Trim();
+        var resolvedDomain = !string.IsNullOrWhiteSpace(domain)
+            ? domain.Trim()
+            : (string.IsNullOrWhiteSpace(wo.Domain) ? tenantId.ToString() : wo.Domain.Trim());
         var lineTo = await ResolveLineSideLocationAsync(tenantId, wo.ItemNum);
         var now = DateTime.Now;
         var effDate = wo.OrdDate?.Date ?? now.Date;
         var pickDate = effDate < now.Date ? now.Date : effDate;
 
         string nbrNo;
+        var remark = "下达自动领料";
+        var source = "Local";
         try
         {
-            var numbers = await _numberRule.NextBatchInCurrentTransactionAsync("SM", domain, 1, account);
+            var numbers = await _numberRule.NextBatchInCurrentTransactionAsync("SM", resolvedDomain, 1, account);
             nbrNo = numbers.FirstOrDefault() ?? throw Oops.Oh("领料单号生成失败");
         }
         catch
         {
-            nbrNo = "SM" + now.ToString("yyyyMMddHHmmss") + YitIdHelper.NextId().ToString()[^5..];
+            // WP9 S2:降级号与 165 号段可区分
+            nbrNo = "SMX" + now.ToString("yyyyMMddHHmmss") + YitIdHelper.NextId().ToString()[^5..];
+            remark = "本地降级";
+            source = "LocalFallback";
         }
 
         var masterRecId = await NextRecIdAsync("NbrMaster");
@@ -69,9 +87,9 @@ public class WorkOrderPickBillService : ITransient
             )
             """,
             new SugarParameter("@RecId", masterRecId),
-            new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+            new SugarParameter("@Domain", resolvedDomain.Length > 8 ? resolvedDomain[..8] : resolvedDomain),
             new SugarParameter("@Nbr", nbrNo),
-            new SugarParameter("@Remark", "下达自动领料"),
+            new SugarParameter("@Remark", remark),
             new SugarParameter("@PickDate", pickDate),
             new SugarParameter("@EffDate", effDate),
             new SugarParameter("@WorkOrd", workOrd),
@@ -103,7 +121,7 @@ public class WorkOrderPickBillService : ITransient
                 )
                 """,
                 new SugarParameter("@RecId", detailRecId),
-                new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain),
+                new SugarParameter("@Domain", resolvedDomain.Length > 8 ? resolvedDomain[..8] : resolvedDomain),
                 new SugarParameter("@Nbr", nbrNo),
                 new SugarParameter("@Line", line),
                 new SugarParameter("@ItemNum", d.ItemNum),
@@ -143,7 +161,8 @@ public class WorkOrderPickBillService : ITransient
             Nbr = nbrNo,
             Created = true,
             LineCount = details.Count,
-            Message = "领料单已生成"
+            Message = source == "LocalFallback" ? "领料单已生成本地降级号" : "领料单已生成",
+            Source = source
         };
     }
 
@@ -154,6 +173,7 @@ public class WorkOrderPickBillService : ITransient
             SELECT Nbr FROM NbrMaster
             WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
               AND IFNULL(TransType, '') = ''
+              AND IFNULL(IsActive, 1) = 1
             ORDER BY RecID DESC LIMIT 1
             """,
             new List<SugarParameter>
@@ -237,14 +257,6 @@ public class WorkOrderPickBillService : ITransient
         return await _db.Ado.GetIntAsync($"SELECT IFNULL(MAX(RecID), 0) + 1 FROM {table}");
     }
 
-    public sealed class PickBillResult
-    {
-        public string Nbr { get; set; } = string.Empty;
-        public bool Created { get; set; }
-        public int LineCount { get; set; }
-        public string Message { get; set; } = string.Empty;
-    }
-
     private sealed class WorkOrderPickRow
     {
         public long RecId { get; set; }