Ver código fonte

feat(mdp): land WP10 pick-bill reliability and outbox ops (Web 2.4.277 / server 1.0.302)

Harden cross-DB pick creation with 165/local transactions, idempotent replay, unified result contract, keeper hard checks (B5), and Outbox admin UI for dead-letter retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 5 dias atrás
pai
commit
e09dc2a77c
22 arquivos alterados com 3009 adições e 278 exclusões
  1. 1 1
      Web/package.json
  2. 1 0
      Web/src/constants/aidopFuncCodes.ts
  3. 43 0
      Web/src/views/aidop/api/mdpOutbox.ts
  4. 135 0
      Web/src/views/aidop/business/components/PickBillResultDialog.vue
  5. 34 12
      Web/src/views/aidop/business/workOrderDispatchList.vue
  6. 322 0
      Web/src/views/aidop/data-platform/outbox.vue
  7. 31 2
      Web/src/views/aidop/s5/api/wmsPick.ts
  8. 10 6
      doc/plan/旧DOP-MES-WMS/对接任务书/README.md
  9. 787 0
      doc/plan/旧DOP-MES-WMS/对接任务书/WP10-跨库成败判定与失败兜底.md
  10. 78 10
      doc/plan/旧DOP-MES-WMS/对接任务书/WP6-对账与运维.md
  11. 199 0
      doc/plan/旧DOP-MES-WMS/对接任务书/WP7-基础数据与任务推送.md
  12. 235 0
      doc/plan/旧DOP-MES-WMS/对接任务书/WP9-领料建单双路径统一.md
  13. 77 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_wp10_s0_card0.py
  14. 31 2
      doc/plan/旧DOP-MES-WMS/对接任务书/执行进度-2026-08-09.md
  15. 14 0
      doc/plan/旧DOP-MES-WMS/对接任务书/附-字段共管矩阵.md
  16. 4 3
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpOutboxEnqueueService.cs
  17. 52 10
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpTargetPushDispatcher.cs
  18. 160 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpOutboxAdminService.cs
  19. 682 214
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs
  20. 82 17
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/KeeperResolveService.cs
  21. 30 1
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/WmsBaseDataService.cs
  22. 1 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

+ 1 - 1
Web/package.json

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

+ 1 - 0
Web/src/constants/aidopFuncCodes.ts

@@ -262,6 +262,7 @@ const FUNC_DEFS: FuncDef[] = [
 	{ code: 'FUNC-S9-013', name: '同步配置中心', paths: ['/aidop/data-platform/sync-tasks'], names: ['aidopDataPlatformSyncTasks'] },
 	{ code: 'FUNC-S9-014', name: '数据任务日志', paths: ['/aidop/data-platform/sync-logs'], names: ['aidopDataPlatformSyncLogs'] },
 	{ code: 'FUNC-S9-015', name: '业务动作日志', paths: ['/aidop/data-platform/action-run-logs'], names: ['aidopDataPlatformActionRunLogs'] },
+	{ code: 'FUNC-S9-016', name: '出站回写队列', paths: ['/aidop/data-platform/outbox'], names: ['aidopDataPlatformOutbox'] },
 ];
 
 function buildLookupMaps() {

+ 43 - 0
Web/src/views/aidop/api/mdpOutbox.ts

@@ -0,0 +1,43 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+export interface MdpOutboxRow {
+	id: number;
+	tenantId?: number | null;
+	targetSourceCode?: string | null;
+	actionCode?: string | null;
+	idemKey?: string | null;
+	payloadJson?: string | null;
+	status?: number | null;
+	retryCount?: number | null;
+	responseJson?: string | null;
+	errorMsg?: string | null;
+	createTime?: string | null;
+	updateTime?: string | null;
+}
+
+export interface MdpOutboxStats {
+	pending: number;
+	success: number;
+	dead: number;
+	oldestPendingMinutes: number;
+	deadLast24h: number;
+}
+
+export function fetchMdpOutboxPage(params: Record<string, unknown>) {
+	return service.get<Paged<MdpOutboxRow>>('/api/aidop/mdp-outbox/page', { params }).then((r) => r.data);
+}
+
+export function fetchMdpOutboxStats() {
+	return service.get<MdpOutboxStats>('/api/aidop/mdp-outbox/stats').then((r) => r.data);
+}
+
+export function retryMdpOutbox(body: { ids?: number[]; allDead?: boolean }) {
+	return service.post<{ reset: number }>('/api/aidop/mdp-outbox/retry', body).then((r) => r.data);
+}

+ 135 - 0
Web/src/views/aidop/business/components/PickBillResultDialog.vue

@@ -0,0 +1,135 @@
+<template>
+	<el-dialog
+		v-model="visible"
+		:title="title"
+		width="720px"
+		destroy-on-close
+		append-to-body
+		@closed="emit('closed')"
+	>
+		<div class="summary-badges">
+			<el-tag type="success" effect="plain">成功 {{ summary.created ?? 0 }}</el-tag>
+			<el-tag type="info" effect="plain">已存在 {{ summary.existed ?? 0 }}</el-tag>
+			<el-tag type="warning" effect="plain">跳过 {{ summary.skipped ?? 0 }}</el-tag>
+			<el-tag type="danger" effect="plain">失败 {{ summary.failed ?? 0 }}</el-tag>
+		</div>
+
+		<el-table :data="items" border max-height="360" class="mt12" size="small">
+			<el-table-column prop="workOrd" label="工单" min-width="120" />
+			<el-table-column prop="result" label="结果" width="90" align="center">
+				<template #default="{ row }">
+					<el-tag :type="resultTag(row.result)" size="small" effect="plain">{{ row.result }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="nbr" label="领料单号" min-width="140" show-overflow-tooltip>
+				<template #default="{ row }">
+					<span v-if="row.nbr" class="nbr" @click="copy(row.nbr!)">{{ row.nbr }}</span>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
+			<el-table-column label="建议" min-width="160">
+				<template #default="{ row }">
+					<el-link v-if="row.reasonCode === 'NO_KEEPER'" type="primary" @click="goKeeper">{{ row.hint || '补物料职责' }}</el-link>
+					<el-link v-else-if="row.reasonCode === 'NO_DETAIL'" type="primary" @click="goDetail(row.workOrd)">{{ row.hint || '补工单明细' }}</el-link>
+					<span v-else>{{ row.hint || '—' }}</span>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<div v-if="writebackPending" class="writeback-tip">165 工单状态回写正在后台进行,稍后刷新可见(可在「出站回写队列」查看进度)</div>
+
+		<template #footer>
+			<el-button @click="visible = false">关闭</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<script setup lang="ts" name="PickBillResultDialog">
+import { computed, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import type { CreatePickBillResult, PickBillItemResult, PickBillSummary } from '../../s5/api/wmsPick';
+
+const props = defineProps<{
+	modelValue: boolean;
+	result: CreatePickBillResult | null;
+}>();
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: boolean): void;
+	(e: 'closed'): void;
+}>();
+
+const router = useRouter();
+const visible = ref(false);
+watch(
+	() => props.modelValue,
+	(v) => {
+		visible.value = v;
+	},
+	{ immediate: true }
+);
+watch(visible, (v) => emit('update:modelValue', v));
+
+const summary = computed<PickBillSummary>(() => props.result?.summary ?? {});
+const items = computed<PickBillItemResult[]>(() => props.result?.items ?? []);
+const writebackPending = computed(() => props.result?.writeback?.state === 'pending');
+
+const title = computed(() => {
+	const code = props.result?.code || 'FAILED';
+	const map: Record<string, string> = {
+		OK: 'MES 建领料成功',
+		PARTIAL: 'MES 建领料部分成功',
+		NOOP: 'MES 建领料无需新建',
+		FAILED: 'MES 建领料失败',
+	};
+	return map[code] || 'MES 建领料结果';
+});
+
+function resultTag(r?: string) {
+	if (r === 'created') return 'success';
+	if (r === 'existed') return 'info';
+	if (r === 'skipped') return 'warning';
+	if (r === 'failed') return 'danger';
+	return 'info';
+}
+
+async function copy(text: string) {
+	try {
+		await navigator.clipboard.writeText(text);
+		ElMessage.success(`已复制 ${text}`);
+	} catch {
+		ElMessage.info(text);
+	}
+}
+
+function goKeeper() {
+	router.push('/aidop/s5/wms-base/emp-duty');
+	visible.value = false;
+}
+
+function goDetail(_workOrd?: string) {
+	ElMessage.info('请在工单明细中维护物料后重试');
+	visible.value = false;
+}
+</script>
+
+<style scoped>
+.summary-badges {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.nbr {
+	color: var(--el-color-primary);
+	cursor: pointer;
+}
+.writeback-tip {
+	margin-top: 12px;
+	color: var(--el-text-color-secondary);
+	font-size: 13px;
+}
+</style>

+ 34 - 12
Web/src/views/aidop/business/workOrderDispatchList.vue

@@ -104,7 +104,7 @@
 			/>
 		</el-dialog>
 
-
+		<PickBillResultDialog v-model="pickResultVisible" :result="pickResult" />
 	</AidopDemoShell>
 </template>
 
@@ -114,6 +114,7 @@ import { useRoute } from 'vue-router';
 import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
 import WorkOrderDispatchForm from './workOrderDispatchForm.vue';
+import PickBillResultDialog from './components/PickBillResultDialog.vue';
 import { useUserInfo } from '/@/stores/userInfo';
 import {
 	fetchWorkOrderDispatchList,
@@ -122,7 +123,7 @@ import {
 	mrpGenerate,
 	type WorkOrderDispatchRow,
 } from '../api/workOrderDispatch';
-import { createPickBill } from '../s5/api/wmsPick';
+import { createPickBill, type CreatePickBillResult } from '../s5/api/wmsPick';
 
 const userInfo = useUserInfo();
 
@@ -298,10 +299,12 @@ async function onMrpGenerate() {
 	}
 }
 
-// ── MES/WMS 下达建领料(165,WP4)──
+// ── MES/WMS 下达建领料(165,WP4 / WP10)──
 const WMS_DOMAIN = '8010';
 const pickCreating = ref(false);
 const pickCreatingWo = ref<string | null>(null);
+const pickResultVisible = ref(false);
+const pickResult = ref<CreatePickBillResult | null>(null);
 
 function resolveWmsDomain(row?: WorkOrderDispatchRow | null) {
 	const d = (row?.domain || '').trim();
@@ -309,7 +312,31 @@ function resolveWmsDomain(row?: WorkOrderDispatchRow | null) {
 	return WMS_DOMAIN;
 }
 
-async function runCreatePickBill(workOrds: string[], domain: string) {
+function toastByCode(res: CreatePickBillResult, single: boolean) {
+	const code = res.code || (res.ok ? 'OK' : 'FAILED');
+	const nbrText =
+		(res.items ?? [])
+			.filter((i) => i.nbr)
+			.map((i) => `${i.workOrd}→${i.nbr}`)
+			.join(';') ||
+		(res.nbrs ?? []).map((n) => `${n.workOrd}→${n.nbr}`).join(';') ||
+		res.message ||
+		'已处理';
+
+	if (single && (res.items?.length ?? 0) <= 1) {
+		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 建领料失败');
+		return;
+	}
+
+	// 批量或多行:用明细弹窗(NOOP 也弹,蓝色标题,不报错)
+	pickResult.value = res;
+	pickResultVisible.value = true;
+}
+
+async function runCreatePickBill(workOrds: string[], domain: string, single: boolean) {
 	const account = userInfo.userInfos?.account ?? 'aidop';
 	const tenantId = domainParamFromCurrentTenantNumber();
 	const res = await createPickBill({
@@ -318,12 +345,7 @@ async function runCreatePickBill(workOrds: string[], domain: string) {
 		userAccount: account,
 		tenantId,
 	});
-	if (!res?.ok) {
-		ElMessage.error(res?.message || 'MES 建领料失败');
-		return;
-	}
-	const nbrText = (res.nbrs ?? []).map((n) => `${n.workOrd}→${n.nbr}`).join(';') || '已处理';
-	ElMessage.success(`MES 领料单已生成:${nbrText}`);
+	toastByCode(res ?? { ok: false, code: 'FAILED', message: '空响应' }, single);
 	await loadList();
 }
 
@@ -339,7 +361,7 @@ async function onCreatePickBill() {
 	}
 	pickCreating.value = true;
 	try {
-		await runCreatePickBill(workOrds, resolveWmsDomain(selectedRows.value[0]));
+		await runCreatePickBill(workOrds, resolveWmsDomain(selectedRows.value[0]), false);
 	} catch (e: any) {
 		ElMessage.error(e?.message || 'MES 建领料失败');
 	} finally {
@@ -354,7 +376,7 @@ async function onCreatePickBillRow(row: WorkOrderDispatchRow) {
 	}
 	pickCreatingWo.value = row.workOrd;
 	try {
-		await runCreatePickBill([row.workOrd], resolveWmsDomain(row));
+		await runCreatePickBill([row.workOrd], resolveWmsDomain(row), true);
 	} catch (e: any) {
 		ElMessage.error(e?.message || 'MES 建领料失败');
 	} finally {

+ 322 - 0
Web/src/views/aidop/data-platform/outbox.vue

@@ -0,0 +1,322 @@
+<template>
+	<AidopDemoShell title="出站回写队列" subtitle="Outbox 死信监控与手工重推;配置类失败保持待推,修复源后可重推或由兜底作业捡起">
+		<template #bar-right>
+			<el-button icon="ele-Refresh" :loading="loading" @click="refresh">刷新</el-button>
+			<el-button type="primary" icon="ele-RefreshRight" :disabled="!selectedIds.length" :loading="retrying" @click="onRetrySelected">
+				重推选中
+			</el-button>
+			<el-button type="danger" plain icon="ele-Warning" :loading="retrying" @click="onRetryAllDead">重推全部死信</el-button>
+		</template>
+
+		<el-row :gutter="12" class="summary-row">
+			<el-col :xs="12" :sm="6">
+				<el-card shadow="never">
+					<div class="summary-label">待推</div>
+					<div class="summary-value">{{ stats.pending }}</div>
+				</el-card>
+			</el-col>
+			<el-col :xs="12" :sm="6">
+				<el-card shadow="never">
+					<div class="summary-label">成功</div>
+					<div class="summary-value">{{ stats.success }}</div>
+				</el-card>
+			</el-col>
+			<el-col :xs="12" :sm="6">
+				<el-card shadow="never">
+					<div class="summary-label">死信</div>
+					<div class="summary-value summary-value--danger">{{ stats.dead }}</div>
+					<div class="summary-sub">近 24h {{ stats.deadLast24h }}</div>
+				</el-card>
+			</el-col>
+			<el-col :xs="12" :sm="6">
+				<el-card shadow="never">
+					<div class="summary-label">最老待推(分钟)</div>
+					<div class="summary-value">{{ stats.oldestPendingMinutes }}</div>
+				</el-card>
+			</el-col>
+		</el-row>
+
+		<el-card shadow="never" class="mb12">
+			<el-form :inline="true" :model="query" @submit.prevent>
+				<el-form-item label="状态">
+					<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
+						<el-option label="待推" :value="0" />
+						<el-option label="成功" :value="1" />
+						<el-option label="死信" :value="2" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="目标源">
+					<el-input v-model="query.targetSourceCode" placeholder="精确匹配" clearable style="width: 180px" />
+				</el-form-item>
+				<el-form-item label="动作编码">
+					<el-input v-model="query.actionCode" placeholder="精确匹配" clearable style="width: 160px" />
+				</el-form-item>
+				<el-form-item label="幂等键">
+					<el-input v-model="query.idemKey" placeholder="模糊匹配" clearable style="width: 200px" />
+				</el-form-item>
+				<el-form-item label="创建时间">
+					<el-date-picker
+						v-model="query.timeRange"
+						type="datetimerange"
+						range-separator="至"
+						start-placeholder="开始"
+						end-placeholder="结束"
+						value-format="YYYY-MM-DD HH:mm:ss"
+						style="width: 360px"
+					/>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" icon="ele-Search" :loading="loading" @click="search">查询</el-button>
+					<el-button icon="ele-RefreshLeft" @click="reset">重置</el-button>
+				</el-form-item>
+			</el-form>
+		</el-card>
+
+		<el-table v-loading="loading" :data="rows" border @selection-change="onSelectionChange">
+			<el-table-column type="selection" width="48" />
+			<el-table-column type="expand">
+				<template #default="{ row }">
+					<div class="log-detail">
+						<div><b>错误:</b>{{ row.errorMsg || '—' }}</div>
+						<div class="mt8"><b>Payload:</b></div>
+						<pre v-if="row.payloadJson" class="log-detail__json">{{ formatJson(row.payloadJson) }}</pre>
+						<span v-else>—</span>
+						<div v-if="row.responseJson" class="mt8"><b>Response:</b></div>
+						<pre v-if="row.responseJson" class="log-detail__json">{{ formatJson(row.responseJson) }}</pre>
+					</div>
+				</template>
+			</el-table-column>
+			<el-table-column prop="id" label="ID" width="90" />
+			<el-table-column prop="actionCode" label="动作编码" min-width="140" show-overflow-tooltip />
+			<el-table-column prop="targetSourceCode" label="目标源" min-width="160" show-overflow-tooltip />
+			<el-table-column prop="idemKey" label="幂等键" min-width="200" show-overflow-tooltip />
+			<el-table-column prop="status" label="状态" width="90" align="center">
+				<template #default="{ row }">
+					<el-tag :type="statusType(row.status)" effect="plain">{{ statusText(row.status) }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="retryCount" label="重试" width="70" align="center" />
+			<el-table-column prop="errorMsg" label="错误摘要" min-width="180" show-overflow-tooltip />
+			<el-table-column prop="updateTime" label="更新时间" width="170" />
+			<el-table-column prop="createTime" label="创建时间" width="170" />
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				layout="total, sizes, prev, pager, next"
+				:page-sizes="[10, 20, 50, 100]"
+				@current-change="load"
+				@size-change="search"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopDataPlatformOutbox">
+import { onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../components/AidopDemoShell.vue';
+import {
+	fetchMdpOutboxPage,
+	fetchMdpOutboxStats,
+	retryMdpOutbox,
+	type MdpOutboxRow,
+	type MdpOutboxStats,
+} from '../api/mdpOutbox';
+
+const loading = ref(false);
+const retrying = ref(false);
+const total = ref(0);
+const rows = ref<MdpOutboxRow[]>([]);
+const selectedIds = ref<number[]>([]);
+const stats = reactive<MdpOutboxStats>({
+	pending: 0,
+	success: 0,
+	dead: 0,
+	oldestPendingMinutes: 0,
+	deadLast24h: 0,
+});
+
+const query = reactive({
+	page: 1,
+	pageSize: 20,
+	status: undefined as number | undefined,
+	targetSourceCode: '',
+	actionCode: '',
+	idemKey: '',
+	timeRange: null as string[] | null,
+});
+
+function statusText(status?: number | null) {
+	if (status === 0) return '待推';
+	if (status === 1) return '成功';
+	if (status === 2) return '死信';
+	return String(status ?? '—');
+}
+
+function statusType(status?: number | null) {
+	if (status === 0) return 'warning';
+	if (status === 1) return 'success';
+	if (status === 2) return 'danger';
+	return 'info';
+}
+
+function formatJson(raw?: string | null) {
+	if (!raw) return '';
+	try {
+		return JSON.stringify(JSON.parse(raw), null, 2);
+	} catch {
+		return raw;
+	}
+}
+
+function onSelectionChange(selection: MdpOutboxRow[]) {
+	selectedIds.value = selection.map((r) => r.id).filter((id) => id != null);
+}
+
+async function loadStats() {
+	try {
+		const res = await fetchMdpOutboxStats();
+		stats.pending = res.pending ?? 0;
+		stats.success = res.success ?? 0;
+		stats.dead = res.dead ?? 0;
+		stats.oldestPendingMinutes = res.oldestPendingMinutes ?? 0;
+		stats.deadLast24h = res.deadLast24h ?? 0;
+	} catch {
+		/* 顶部统计失败不阻断列表 */
+	}
+}
+
+async function load() {
+	loading.value = true;
+	try {
+		const [from, to] = query.timeRange ?? [];
+		const res = await fetchMdpOutboxPage({
+			page: query.page,
+			pageSize: query.pageSize,
+			status: query.status,
+			targetSourceCode: query.targetSourceCode || undefined,
+			actionCode: query.actionCode || undefined,
+			idemKey: query.idemKey || undefined,
+			createTimeFrom: from || undefined,
+			createTimeTo: to || undefined,
+		});
+		total.value = res.total ?? 0;
+		rows.value = res.list ?? [];
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function refresh() {
+	await Promise.all([loadStats(), load()]);
+}
+
+function search() {
+	query.page = 1;
+	refresh();
+}
+
+function reset() {
+	query.status = undefined;
+	query.targetSourceCode = '';
+	query.actionCode = '';
+	query.idemKey = '';
+	query.timeRange = null;
+	search();
+}
+
+async function onRetrySelected() {
+	if (!selectedIds.value.length) {
+		ElMessage.warning('请先勾选要重推的行');
+		return;
+	}
+	retrying.value = true;
+	try {
+		const res = await retryMdpOutbox({ ids: selectedIds.value });
+		ElMessage.success(`已重置 ${res.reset ?? 0} 条,已触发立即推送`);
+		await refresh();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '重推失败');
+	} finally {
+		retrying.value = false;
+	}
+}
+
+async function onRetryAllDead() {
+	try {
+		await ElMessageBox.confirm(
+			`将重置全部死信(当前约 ${stats.dead} 条)为待推并立即触发推送,是否继续?`,
+			'重推全部死信',
+			{ type: 'warning', confirmButtonText: '确认重推', cancelButtonText: '取消' }
+		);
+	} catch {
+		return;
+	}
+	retrying.value = true;
+	try {
+		const res = await retryMdpOutbox({ allDead: true });
+		ElMessage.success(`已重置 ${res.reset ?? 0} 条死信,已触发立即推送`);
+		await refresh();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '重推失败');
+	} finally {
+		retrying.value = false;
+	}
+}
+
+onMounted(() => {
+	refresh();
+});
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.mt8 {
+	margin-top: 8px;
+}
+.summary-row {
+	margin-bottom: 12px;
+}
+.summary-label {
+	font-size: 13px;
+	color: var(--el-text-color-secondary);
+	margin-bottom: 8px;
+}
+.summary-value {
+	font-size: 28px;
+	font-weight: 600;
+	line-height: 1.2;
+}
+.summary-value--danger {
+	color: var(--el-color-danger);
+}
+.summary-sub {
+	margin-top: 4px;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+.log-detail {
+	padding: 8px 24px;
+	line-height: 24px;
+}
+.log-detail__json {
+	margin-top: 8px;
+	padding: 12px;
+	background: var(--el-fill-color-light);
+	border-radius: 6px;
+	font-size: 12px;
+	white-space: pre-wrap;
+	word-break: break-all;
+}
+</style>

+ 31 - 2
Web/src/views/aidop/s5/api/wmsPick.ts

@@ -1,6 +1,6 @@
 import service from '/@/utils/request';
 
-/** WP4 · 下达建领料(165 NbrMaster/Detail,NonUnify) */
+/** WP4 / WP10 · 下达建领料(165 NbrMaster/Detail,NonUnify) */
 export interface CreatePickBillBody {
 	workOrd?: string;
 	domain?: string;
@@ -8,10 +8,39 @@ export interface CreatePickBillBody {
 	tenantId?: number;
 }
 
+export interface PickBillItemResult {
+	workOrd?: string;
+	result?: 'created' | 'existed' | 'skipped' | 'failed' | string;
+	nbr?: string;
+	detailCount?: number;
+	reasonCode?: string;
+	reason?: string;
+	hint?: string;
+}
+
+export interface PickBillSummary {
+	requested?: number;
+	created?: number;
+	existed?: number;
+	skipped?: number;
+	failed?: number;
+}
+
+export interface PickBillWriteback {
+	enqueued?: number;
+	state?: string;
+	reconciled?: boolean | null;
+}
+
 export interface CreatePickBillResult {
 	ok: boolean;
-	message?: string;
+	code?: 'OK' | 'PARTIAL' | 'NOOP' | 'FAILED' | string;
+	summary?: PickBillSummary;
+	items?: PickBillItemResult[];
+	writeback?: PickBillWriteback;
 	trace?: string;
+	message?: string;
+	/** @deprecated 旧字段,兼容短暂过渡 */
 	workOrds?: string[];
 	nbrs?: Array<{ workOrd?: string; nbr?: string; user1?: string; detailCount?: number }>;
 	outboxEnqueued?: number;

+ 10 - 6
doc/plan/旧DOP-MES-WMS/对接任务书/README.md

@@ -17,14 +17,14 @@
 
 | 状态 | 工作包 |
 |------|-------|
-| **可直接派发** | WP1、WP2、WP8、WP7 §2/§3;WP3(写权限验证后) |
-| **暂不可派发** | WP4(字段矩阵仍是调研任务,D1/D3/D4/D6 未决)、WP7 §4(受 A4)、WP5(需真实 APP 环境与人)、WP6(依赖 WP5) |
+| **可直接派发** | **WP7 §3.5 物料职责补数(= WP10 卡片 0,无任何代码依赖,可最先执行)**;WP1、WP2、WP8、WP7 §2/§3;WP3(写权限验证后);**WP10 S0/S1/S2/S3/S4a(D10、D11 已定;派发前先读其 §11 已知空白,卡片见 §12)** |
+| **暂不可派发** | WP4(字段矩阵仍是调研任务,D1/D3/D4/D6 未决)、WP7 §4(受 A4)、WP5(需真实 APP 环境与人)、WP6(依赖 WP5)、**WP9(D7/D8/D9 未决;其 S1 盘点步可先行)**、**WP10 S4b(D12 未决)** |
 
 | 文档 | 内容 | 前置 |
 |------|------|------|
 | [`00-总体方案.md`](./00-总体方案.md) | 约束、环境实测、对接契约、目标架构、全局验收 | — |
 | [`00B-执行须知(模型输入契约).md`](./00B-执行须知(模型输入契约).md) | **派发任何工作包时必须一并交付**:连接、代码落点、产出规范、自检、禁止事项 | — |
-| [`执行进度-2026-08-09.md`](./执行进度-2026-08-09.md) | **本轮已落地内容与阻断项**(后端 1.0.300) | — |
+| [`执行进度-2026-08-09.md`](./执行进度-2026-08-09.md) | **本轮已落地内容与阻断项**(后端 1.0.301 / 前端 2.4.276) | — |
 | [`附录A-手册操作覆盖矩阵.md`](./附录A-手册操作覆盖矩阵.md) | **操作手册 49 个操作逐条 → 承接工作包**,判定「是否满足手册」的总账 | — |
 | [`WP1-入站抽数补齐.md`](./WP1-入站抽数补齐.md) | **冷链**:启用 165 源 + 36 张表的实体与字段映射 | — |
 | [`WP2-单号服务.md`](./WP2-单号服务.md) | C# 复刻 `pr_SFM_GetOrdNbr`,与 WMS APP 共用计数器 | — |
@@ -34,6 +34,8 @@
 | [`WP8-实时回读热链路.md`](./WP8-实时回读热链路.md) | **在途键集合窄轮询,执行状态 P95 ≤ 10 秒可见** | WP1 |
 | [`WP5-主链路端到端验证.md`](./WP5-主链路端到端验证.md) | 四条主链闭环验证(领料 / 收货上架 / 报工 / 发货)+ 时延实测 | WP1–WP4、WP7、WP8 |
 | [`WP6-对账与运维.md`](./WP6-对账与运维.md) | 对账作业、监控、失败处置 | WP5 |
+| [`WP9-领料建单双路径统一.md`](./WP9-领料建单双路径统一.md) | **本库 SM 与 165 SM 两条建单路径的收敛**:权威库决策(D7–D9)、取号一致性、单一入口、S5 切源 | WP2、WP4 |
+| [`WP10-跨库成败判定与失败兜底.md`](./WP10-跨库成败判定与失败兜底.md) | **无分布式事务下的成败判定与自愈**:事务边界、孤儿解卡(D10)、统一返回契约与错误码、Outbox 退避与死信管理(D11/D12) | WP3、WP4 |
 
 **并行关系**:WP1 与 WP2 可并行 → WP3 → **WP7** → WP4 → WP5 → WP6;**WP8 在 WP1 后并行开工**。
 
@@ -60,10 +62,12 @@
 | 阻断项 | 影响 | 归属 |
 |--------|------|------|
 | 165 写权限未验证 | 整个回写方案不成立 | WP3 §2 |
-| **物料职责(`EmpWorkDutyMaster`)在 165 上为 0 行** | **领料单根本建不出来**,WP5 场景 1 必然失败 | WP7 §3、WP5 §2 |
+| **物料职责(`EmpWorkDutyMaster`)在 165 上为 0 行** | 领料单**仍会建出来,但保管员为空**(`KeeperResolveService` 未实现 WP7 §3.2 的中断校验,未命中只跳过)——是**静默错单**,比建不出来更难发现。WP5 场景 1 拿不到有效 `User1`。**已备派发卡片,可执行** | 补数见 [WP7 §3.5](./WP7-基础数据与任务推送.md#35-派发卡片--empworkdutymaster-联调补数);实现差距见 WP7 §3.4 |
 | 字段共管矩阵未固化 | 回写可能覆盖执行数据 | WP4 §2 |
 | WP4 的 **D1、D3、D4、D6** 未定(D2、D5 已结) | 影响复刻范围 | WP4 §6 |
-| WP7 的 B1–B4 决策未定 | 影响基础数据归属 | WP7 §7 |
+| WP7 的 **B1、B3、B4** 未定(**B2 已查实结案、B5 已定**) | B1 影响收货任务执行人来源;B3 只读范围;B4 是上线前组织约定 | WP7 §7 |
+| **F13 卡死会阻碍联调**(实测活例 `M500000005`) | 建单后回写失败的工单**永久不可复用**(重放被幂等跳过),联调期需反复手工清库。165 为开发测试机故非生产事故,但 S1 属**测试前置项**。**已解阻断,可派发** | WP10 §7.1、§12 卡片 B |
+| WP10 的 D12(Outbox 退避参数与告警渠道)未定 | 仅阻断 S4b(上线前项);不影响 S0–S4a | WP10 §3 |
 | **退料单发起方未知(D6)** | 疑由 BPM 引擎发起;未确认前不投入反编译工时 | WP4 §4.4 |
 | **PDA 是否真有待发料任务(A4)** | 决定 WP7 §4 全量实施还是裁剪 | 附录 A §4.1 |
 | `MissedPrint` 热查询方案未定案(E1) | 标签维度的实时性无法落地 | WP8 §4.3 |
@@ -86,4 +90,4 @@
 
 > 165 上单据表当前**全部 0 行**(环境被清理过),主数据与 `InvTransHist` 有数据。联调需造数,详见 WP5 §7。
 >
-> 另外 `EmpWorkDutyMaster`(物料职责)与 `LinePrinter`(打印机)也是 0 行,但它们属**基础数据**而非单据,必须先补齐才能联调,见 WP5 §2 场景 0。
+> 另外 `EmpWorkDutyMaster`(物料职责)与 `LinePrinter`(打印机)也是 0 行,但它们属**基础数据**而非单据,必须先补齐才能联调,见 WP5 §2 场景 0。物料职责的补数已备派发卡片:[WP7 §3.5](./WP7-基础数据与任务推送.md#35-派发卡片--empworkdutymaster-联调补数)。

+ 787 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/WP10-跨库成败判定与失败兜底.md

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

+ 78 - 10
doc/plan/旧DOP-MES-WMS/对接任务书/WP6-对账与运维.md

@@ -3,10 +3,12 @@
 | 项 | 内容 |
 |----|------|
 | 目标 | 在「跨库无事务」前提下,保证两库长期不漂移,并让失败可发现、可定位、可修复 |
-| 前置 | WP5 主链跑通 |
+| 前置 | WP5 主链跑通;**Outbox 监控与重推入口由 [`WP10`](./WP10-跨库成败判定与失败兜底.md) S4a 先行交付**,本包不重复建设 |
 | 产出 | 对账作业 + 监控指标 + 失败处置 SOP + 重放入口 |
 | 不做 | 不做自动修复(对账只报差异,修复动作需人工确认后触发) |
 
+> **与 WP10 的分工**:WP10 管**事中**(单次动作的成败判定与自愈),WP6 管**事后**(长期漂移的发现与处置)。WP10 §7 已实测到两类需纳入本包对账的对象:**孤儿单**(165 有主单无明细)与 **Outbox 死信**;另新增一类**跨库半成品工单**(见 §2.4),该类在 WP10 中编号 F13。
+
 ---
 
 ## 1. 为什么必须有对账
@@ -23,7 +25,9 @@
 |------|------|------|------|
 | **回写对账** | 新系统计划域产生的单据 vs 165 实际存在的行 | 每小时 | 按业务键比对存在性与关键列 |
 | **入站对账** | 165 源表行数/校验和 vs `mdp_std_*` | 每日 | 行数 + 关键数值列 SUM 比对 |
-| **Outbox 健康** | `mdp_outbox` 积压与失败 | 每 5 分钟 | 状态分布统计 |
+| **Outbox 健康** | `mdp_outbox` 积压与失败 | 每 5 分钟 | 状态分布统计;死信清单见 WP10 S4a 监控页 |
+| **跨库半成品**(新增,源自 WP10 F13) | 单据已建但配套副作用缺失的工单 | 每小时 | 见 §2.4 |
+| **孤儿单**(新增,源自 WP10 F1) | 165 有主单无明细的领料单 | 每日 | 见 §2.4 |
 
 ### 2.2 回写对账
 
@@ -59,6 +63,61 @@ SELECT COUNT(*) AS cnt, SUM(<对应列>) AS chk FROM <mdp_std_表> WHERE source_
 
 > `InvTransHist` 100 万行,对账只比对**滚动窗口内**的增量,不做全表。
 
+### 2.4 跨库半成品与孤儿单(源自 WP10)
+
+这两类差异**不会被 §2.2 的存在性比对发现**——单据两侧都在,差的是配套副作用或从表,所以要单列。
+
+**① 跨库半成品工单**:领料单已建成,但工单头/里程碑工序未回写。WP10 §7.1 实测到活例 `M500000005`。
+
+```sql
+-- 165:有 SM 领料单、却查不到对应工单头
+SELECT m.Nbr, m.WorkOrd, m.CreateTime
+FROM NbrMaster m
+WHERE m.Type = 'SM' AND ISNULL(m.IsActive, 1) = 1
+  AND NOT EXISTS (
+        SELECT 1 FROM WorkOrdMaster w
+         WHERE w.Domain = m.Domain AND w.WorkOrd = m.WorkOrd);
+
+-- 165:工单头存在但未推进到 r(本库已下达时应为 r)
+SELECT w.Domain, w.WorkOrd, w.Status, w.Batch
+FROM WorkOrdMaster w
+JOIN NbrMaster m ON m.Domain = w.Domain AND m.WorkOrd = w.WorkOrd AND m.Type = 'SM'
+WHERE ISNULL(w.Status, '') <> 'r';
+```
+
+**② 孤儿领料单**:165 有主单无明细,SQL 见 [`WP10`](./WP10-跨库成败判定与失败兜底.md) §7.3 查询 ①。
+
+| 差异 | 含义 | 处置 |
+|------|------|------|
+| 有领料单、无工单头 | 工单头回写丢失(Outbox 死信或被幂等跳过) | **重放建单接口**即可(WP10 S1 第 3 节「补做」会自动收敛到终态),不要手工改 165 |
+| 有工单头但 `Status<>'r'` | 回写被 MES/WMS 侧覆盖,或 UPSERT 未执行 | 先查字段共管矩阵确认该列归属,再决定重放还是接受现值 |
+| 孤儿单(有主无明细) | 165 建单中途失败 | 不阻塞业务(WP10 S1 已让它不再卡住重建);清单交客户 DBA 决定是否清理,**我方不自动删** |
+
+> **重要**:这三类的修复动作**都不是直接改 165**,而是重放上游动作。直接改 165 会绕过幂等与共管矩阵校验。
+
+### 2.5 物料号字符集不变量(源自 WP7 §3.6 决策 B5)
+
+保管员解析的区间匹配(`ItemNum1 <= ItemNum <= ItemNum2`)之所以能用 `Ordinal` 比较而与 165 存储过程的 `Chinese_PRC_CI_AS` 结果一致,**前提是物料号只含 `[0-9A-Z]`**。该前提在 Domain 8010 上实测成立(16,150 条 0 违例),但**不是全库成立**(Domain 2410 有 2 条含中文与连字符)。前提一旦被破坏,解析结果会**静默地**与 165 不一致。
+
+因此列为**每日对账项**,结果非 0 即告警,并回到 WP7 §3.6 重新评估比较口径:
+
+```sql
+-- 165 与本地各跑一次(本地 MySQL 用 REGEXP_LIKE(col,'^[0-9A-Z]+$','c') 改写)
+SELECT 'ItemMaster' AS src, COUNT(*) AS violations
+  FROM ItemMaster WHERE Domain='8010'
+   AND ItemNum COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%'
+UNION ALL
+SELECT 'EmpWorkDutyMaster', COUNT(*)
+  FROM EmpWorkDutyMaster WHERE Domain='8010'
+   AND (ItemNum1 COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%'
+     OR ItemNum2 COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%');
+```
+
+| 差异 | 含义 | 处置 |
+|------|------|------|
+| `ItemMaster` 出现违例 | 新接入的物料号超出约定字符集 | **不要**急于放宽校验。先确认该物料是否进入领料链;受影响的建单会报 `KEEPER_RULE_UNSAFE`,按提示人工指派保管员 |
+| `EmpWorkDutyMaster` 出现违例 | 写入边界校验被绕过(多半是直接改库,未走 WP7 §2 的维护 API) | 修正该职责行并追查绕过途径;直接改库会跳过归一与区间反向校验 |
+
 ---
 
 ## 3. 监控指标
@@ -66,8 +125,10 @@ SELECT COUNT(*) AS cnt, SUM(<对应列>) AS chk FROM <mdp_std_表> WHERE source_
 | 指标 | 阈值 | 说明 |
 |------|------|------|
 | `mdp_outbox` 待推积压(`status=0`) | > 100 持续 10 分钟 | 回写通道阻塞 |
-| `mdp_outbox` 失败数(`status=2`) | > 0 | 需人工介入 |
-| `mdp_outbox` 最老待推记录年龄 | > 15 分钟 | 比积压量更敏感 |
+| `mdp_outbox` 失败数(`status=2`) | > 0 | 需人工介入。**采集与告警由 WP10 S4b 的 `MdpOutboxDeadLetterAlertJob` 实现,本包不重复建** |
+| `mdp_outbox` 最老待推记录年龄 | > 15 分钟 | 比积压量更敏感;同上由 WP10 S4b 采集 |
+| 跨库半成品工单数(§2.4) | > 0 | 领料单已建但工单头未回写 |
+| 孤儿领料单数(§2.4) | > 0 | 165 建单中途失败的残留 |
 | 入站失败(`mdp_sync_log.status='FAILED'`) | 近 1h > 0 | 源不可达或结构变更 |
 | 入站水位滞后 | `mdp_entity.last_sync_to` 落后 > 30 分钟 | 抽数停摆 |
 | 165 探活 | 失败 | `MdpSourceHealthCheckJob` |
@@ -80,28 +141,35 @@ SELECT COUNT(*) AS cnt, SUM(<对应列>) AS chk FROM <mdp_std_表> WHERE source_
 
 ### 4.1 Outbox 失败(`status=2`)
 
-1. 查 `error_msg` 定位原因
+1. 在 **WP10 S4a 的「出站回写队列」页**(`/aidop/data-platform/outbox`,`FUNC-S9-016`)按 `status=2` 筛选,展开看 `error_msg` 全文
 2. 分类处理:
 
    | 原因 | 处置 |
    |------|------|
-   | 165 连接失败 | 恢复连接后重放 |
+   | 165 连接失败 | 恢复连接后重推 |
+   | **路由未解析**(`SOURCE_ROUTE_UNRESOLVED`) | 到「数据源管理」补全 `source_type`/`DbHost`/`DbName` 后重推。**WP10 §7.1 的故障即此类** |
    | `expect` 条件不满足(并发冲突) | 人工判断当前值是否可接受;可接受则关闭,否则重新编排后重放 |
    | 表/列不在白名单 | 代码缺陷,回 WP3/WP4 修 |
    | 主键冲突 | 检查幂等键是否正确,可能是重复入队 |
 
-3. 重放:把 `status` 置回 0、`retry_count` 置 0,等 Job 重扫
+3. 重推:页面上点「重推选中」,或调 §4.2 的端点
 
-### 4.2 重放入口
+### 4.2 重放入口(**以 WP10 S4a 为准**)
 
-提供一个受控接口(需管理员权限)
+> **本节原先自拟的 `POST /api/aidop/mdp/outbox/replay` 已作废**,避免与 WP10 重复建设两套。实际端点由 WP10 S4a 交付
 
 ```
-POST /api/aidop/mdp/outbox/replay   { "ids": [...] }  或  { "idemKeys": [...] }
+POST /api/aidop/mdp-outbox/retry    { "ids": [1,2,3] }  或  { "allDead": true }
+GET  /api/aidop/mdp-outbox/page     按 status/source/action/idemKey/时间 筛选
+GET  /api/aidop/mdp-outbox/stats    { pending, success, dead, oldestPendingMinutes, deadLast24h }
 ```
 
+需管理员权限(**不得** `[AllowAnonymous]`)。`ids` 与 `allDead` 都为空时报错,不允许全表重置。
+
 **要求**:重放必须走与首次相同的幂等逻辑,不得绕过。
 
+> **业务级重放**(如 §2.4 的半成品工单)不走这个端点,而是**重放上游业务接口**(例如再调一次建单),由 WP10 S1 第 3 节的「补做」收敛到终态。
+
 ### 4.3 入站失败
 
 1. 查 `mdp_sync_log.error_message`

+ 199 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/WP7-基础数据与任务推送.md

@@ -114,6 +114,204 @@ LEFT JOIN LinePrinter p
 - 这两张表的入站延迟 ≤ 5 分钟;
 - 建单事务开始前做一次「职责版本号」校验(比对本地与 165 的 `MAX(UpdateTime)`),不一致则先触发一次同步再建单。
 
+> **现状**:`KeeperResolveService` 目前直接查 165(`MdpSourceScopeFactory.GetScopeAsync("DOPDEMORQ_SQLSERVER")`),未走本地副本。本轮不改,登记为待优化项。
+
+---
+
+### 3.4 ⚠️ 已实现代码与 §3.1/§3.2 的差距(**补数前必读**)
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/KeeperResolveService.cs` 是当前唯一实现,它**只做了 §3.1 的一小部分**:
+
+| §3.1/§3.2 要求 | 现状 | 后果 |
+|---------------|------|------|
+| `WHERE it.Duty LIKE '%iss-wo%'` 过滤 | ❌ **未实现**,取该 `Domain` 下**全部**职责行 | 任何职责行都可能被当成领料保管员 |
+| `JOIN #nbrDetails ON nbr.GroupBy = it.DutyType` | ❌ 未实现 | `DutyType` 完全未参与匹配 |
+| `JOIN LocationMaster` + `Bonded` / `Typed='Bond'` 分流 | ❌ 未实现 | 保税与非保税走同一条路径,`LocationFrom`/`LocationTo` 未按分流赋值 |
+| `ItemNum1 <= item <= ItemNum2` 字符串区间 | ✅ 已实现(`string.Compare` + `OrdinalIgnoreCase`,**内存比较**) | 与 SQL Server 排序规则可能有差异,见下方注意 |
+| `LinePrinter` 解析 `Address` | ❌ 未实现 | 明细 `Address` 目前由建单侧填工单尾号,非打印机地址 |
+| §3.2 校验一:`Location` 为空 → **中断** | ❌ 未实现 | 不中断 |
+| §3.2 校验二:未匹配到职责 → **中断**「以下物料未设置保管员」 | ❌ **未实现**,未命中只是 `continue` 跳过 | **建单照常进行**,`User1`/`User2` 少人甚至为空 |
+
+**两个直接影响,派发时必须一并交代:**
+
+1. **补数只解决「有数据」,不解决「校验缺失」。** 即使职责数据补齐,只要某个物料没覆盖到,现在也不会报错,而是静默少一个保管员。
+2. **[`WP10`](./WP10-跨库成败判定与失败兜底.md) §4.3 的 `NO_KEEPER` 错误码依赖 §3.2 校验二。** WP10 S2 要实现该错误码,就必须同时把这条硬校验补上——否则错误码永远不会被触发。已在 WP10 §11 空白表第 7 项登记。
+
+> **匹配口径**:现实现在内存里用 `OrdinalIgnoreCase` 比较,与 §3.1 原始逻辑(SQL Server 库排序规则)、与旧 DOP C#(`string.Compare` 默认重载)**都不相同**。这个问题已有定论,见 **§3.6**。
+
+---
+
+### 3.5 派发卡片 · `EmpWorkDutyMaster` 联调补数
+
+> **为什么单列**:165 上该表 **0 行**,导致领料建单的成功路径跑不通,进而挡住 WP10 S1 验收表第 5/6 条与 WP5 场景 1。本卡片只做**联调级最小补数**,不是生产数据治理。
+
+#### 表结构(**已实测确认,勿照抄本库实体**)
+
+| 列 | 类型 | 必填 | 说明 |
+|----|------|------|------|
+| `Domain` | `nvarchar(8)` | ✅ | `8010` |
+| `Employee` | `nvarchar(24)` | | 保管员工号,须在 `EmployeeMaster` 存在 |
+| **`Line`** | **`int`** | ✅ **NOT NULL** | 与 `LinePrinter.Line` 语义不同,此处填 `0` 即可 |
+| `ItemNum1` / `ItemNum2` | `nvarchar(24)` | | 物料号**字符串区间**上下界 |
+| `Location` | `nvarchar(8)` | | 保管库位,须在 `LocationMaster` 存在。**§3.2 要求非空** |
+| `Duty` | `nvarchar(512)` | | 须含 `iss-wo`(现代码不校验,但按 §3.1 原义填,避免将来补齐过滤后失配) |
+| `DutyType` | `nvarchar(120)` | | 现代码未用,仍建议填 |
+| `ProdLine` | `nvarchar(8)` | | 可空 |
+| **`StdResponseTime`** | **`decimal`** | ✅ **NOT NULL** | 填 `30` |
+| `RecID` | `int` | — | **IDENTITY,不要插** |
+| `EmpType` / `Ufld1` / `Ufld2` | `nvarchar` | | 可空 |
+
+#### 可用的现成基础数据(已实测)
+
+| 项 | 可用值 |
+|----|--------|
+| 库位(非供应商类) | `1000` 材料待检库、**`1001` 材料合格库(领料首选)**、`1005` 辅料库、`5006`/`5007` 线边库 |
+| 员工(`LocationMaster.Storer` 中已登记的保管员) | `G00302`、`G00305`、`G00307`、`G00394`(库位 `1000` 的 Storer 列表)、`G02810`(库位 `10000005`) |
+| 联调工单物料范围 | `M500000005` 有 **35 个不同物料**,字符串区间为 `1A0043F` ~ `ABC431` |
+
+#### 做法(**优先走 API,顺带验证 WP7 §2**)
+
+**方式一(推荐)**:调用 WP7 §2 已交付的维护接口,走完整的 Outbox 回写链路。
+
+```
+POST /api/aidop/wms-base/upsert
+{
+  "table": "EmpWorkDutyMaster",
+  "row": {
+    "Domain": "8010", "Employee": "G00302", "Line": 0,
+    "ItemNum1": "0", "ItemNum2": "ZZZZZZZZZZZZZZZZZZZZZZZZ",
+    "Location": "1001", "Duty": "iss-wo", "DutyType": "MAT",
+    "ProdLine": "", "StdResponseTime": 30
+  }
+}
+```
+
+> 字段名以 `WmsBaseDataService` 实际定义为准,执行者先读该服务再拼 body。**这个兜底区间会让所有物料都命中同一个保管员**,仅供联调;生产数据须按实际职责分段。区间边界的书写规则见 **§3.6**(必须全大写、无空格、只含 `[0-9A-Z]`)。
+
+**方式二(备选,API 不通时)**:直接在 165 执行。
+
+```sql
+INSERT INTO EmpWorkDutyMaster
+  (Domain, EmpType, Employee, Line, ItemNum1, ItemNum2, ProdLine, Location,
+   Duty, DutyType, StdResponseTime, CreateUser, UpdateUser, CreateTime, UpdateTime)
+VALUES
+  ('8010', '', 'G00302', 0, '0', 'ZZZZZZZZZZZZZZZZZZZZZZZZ', '', '1001',
+   'iss-wo', 'MAT', 30, 'aidop', 'aidop', GETDATE(), GETDATE());
+```
+
+#### 验收
+
+| # | 检查 | 期望 |
+|---|------|------|
+| 1 | `SELECT COUNT(*) FROM EmpWorkDutyMaster WHERE Domain='8010'` | ≥ 1 |
+| 2 | 职责行的 `Location` 在 `LocationMaster` 中存在且非空 | 满足 §3.2 校验一 |
+| 3 | 职责行的 `Employee` 在 `EmployeeMaster` 中存在 | 避免 `MobileTask` 推送时执行人无效 |
+| 4 | 对 `M500000005` 调建单接口 | `NbrMaster.User1` **非空**且为 `G00302`;`User2` 为 `["G00302"]` |
+| 5 | 方式一还需检查 | `mdp_outbox` 中该条 `WMS_BASE_EMPWORKDUTYMASTER` 消息 `status=1` |
+
+> 走方式一时**顺带验证了 WP7 §2 的六表回写链路**;若失败,先按 [`WP10`](./WP10-跨库成败判定与失败兜底.md) S4a 的监控页定位,不要绕过 API 直接改库。
+
+---
+
+### 3.6 决策 B5 · 物料号区间的比较口径(**已定,按此实现**)
+
+#### 问题
+
+同一个「`ItemNum1 <= ItemNum <= ItemNum2`」判断,链路上存在**四套**排序语义:
+
+| # | 位置 | 语义 | 特点 |
+|---|------|------|------|
+| 1 | `pr_SFM_AutoCreateIssuedFlow`(165) | SQL Server **`Chinese_PRC_CI_AS`**(实测确认,库级与列级一致) | 语言学排序、**大小写不敏感**、尾空格补齐 |
+| 2 | 旧 DOP C# `ResourceExamineAppService.cs:5733` | `string.Compare(a, b)` 默认重载 = **CurrentCulture + 大小写敏感** | 随机器区域设置/ICU 版本变化 |
+| 3 | 现 `KeeperResolveService.cs:47-49` | **`OrdinalIgnoreCase`** | 码点比较、大小写不敏感 |
+| 4 | 本地 MySQL 副本(若按 §3.3 改为本地解析) | **`utf8mb4_unicode_ci`**(实测) | 又一套语言学排序 |
+
+其中 **2 是最差的一套**:它大小写敏感,因此与 165 存储过程(CI)的判定结果天然对不上;且 `CurrentCulture` 在容器里随 locale 漂移,同一份数据在不同机器上可能算出不同保管员。**不要因为「与旧 DOP 保持一致」就去复刻它。**
+
+#### 关键实测:四套语义在本项目数据上其实是重合的
+
+| 数据集 | 总数 | 含小写 | 含连字符 | 含空格 | 非 `[0-9A-Z]` | 最大长度 |
+|--------|------|--------|---------|--------|--------------|---------|
+| 165 `ItemMaster` Domain **8010** | 16,150 | 0 | 0 | 0 | **0** | 9 |
+| 本地 `ItemMaster` Domain **8010** | 16,150 | — | — | 0(无首尾空格) | **0** | 9 |
+| 本地 `ItemMaster` Domain 2410(不在本期范围) | 5,958 | — | — | — | **2** | 13 |
+
+在场物料号的字符集**恰好是 `[0-9A-Z]`**。在这个字符集内、且无首尾空格时:
+
+- 数字 `0x30–0x39` 与大写字母 `0x41–0x5A` 的**相对顺序,在上述四套语义里完全一致**(数字在前、字母在后,各自区间内自然序);
+- 没有大小写可折叠,没有权重可变的标点(连字符/撇号),没有音标,不触发尾空格补齐差异。
+
+**结论:这四套语义在 `^[0-9A-Z]+$` 且无首尾空格的输入上产生完全相同的排序。** 所以正确的解法不是"在 C# 里模拟 SQL 排序规则",而是**把输入约束在四者重合的子集内,并把这个约束真正强制住**。
+
+> Domain 2410 那 2 条违例(`9.0314.01.001`、`钉11匣转向杆-加长0.3`)恰好证明了该字符集**不是全库都成立的**——它同时含中文、连字符和小数点,正是语言学排序与码点排序分歧最大的形态。所以下面第 2、3、4 步不是形式主义。
+
+#### 方案(四步,缺一不可)
+
+**① 比较口径钉死为 `Ordinal`。**
+
+`KeeperResolveService` 中先对**两侧**做 `Trim()` + `ToUpperInvariant()`,再用 `StringComparison.Ordinal` 比较:
+
+```csharp
+static string Norm(string? s) => (s ?? string.Empty).Trim().ToUpperInvariant();
+// …
+var key = Norm(item);
+var ewdm = duties.Find(s =>
+    string.Compare(Norm(s.ItemNum1), key, StringComparison.Ordinal) <= 0
+ && string.Compare(key, Norm(s.ItemNum2), StringComparison.Ordinal) <= 0);
+```
+
+选 `Ordinal` 而非 `OrdinalIgnoreCase` 的理由:大小写已由 `ToUpperInvariant` 归一,再叠加 IgnoreCase 只是掩盖未归一的输入。`Ordinal` 是唯一在 **.NET 版本、操作系统、容器 locale、ICU/NLS 后端**之间都逐位稳定的比较方式。
+
+- 用 `ToUpperInvariant()` 而非 `ToUpper()`:避免土耳其语 locale 下 `i → İ` 的经典陷阱。
+- `Trim()` 不可省:SQL Server 的 `nvarchar` 比较会补齐尾空格(`'A0000 ' = 'A0000'` 为真),.NET 不会。若有人在 `ItemNum1` 末尾多敲一个空格,**SQL 认为边界命中而 .NET 认为不命中**——这是本主题下唯一在当前数据上真实可触发的分歧点,且发生在人工录入的边界列上。
+
+**② 在写入边界强制不变量**(`WmsBaseDataService` 的 `EmpWorkDutyMaster` upsert):
+
+- 落库前归一:`Trim()` + `ToUpperInvariant()`;
+- 校验 `^[0-9A-Z]{1,24}$`,不合规**拒绝**并返回明确文案(不要静默改写用户输入之外的内容);
+- 校验 `Ordinal(ItemNum1) <= Ordinal(ItemNum2)`,否则拒绝——区间反向的职责行永远不可能命中,属静默失效。
+
+这一步是整个方案成立的前提:它把"字符集恰好是 `[0-9A-Z]`"从**观察到的巧合**变成**被强制的约束**。
+
+**③ 解析时做防御性拒绝,不猜。**
+
+`KeeperResolveService` 若发现传入的 `ItemNum` 或某条职责边界归一后仍不匹配 `^[0-9A-Z]+$`,**不得**继续用 `Ordinal` 猜一个结果,应中断并报 `KEEPER_RULE_UNSAFE`(或复用 `NO_KEEPER` 但 `hint` 区分),提示"物料号含超出约定字符集的字符,区间匹配口径不可靠,请人工指派保管员"。
+
+理由:一旦将来接入 Domain 2410 这类数据,静默算出一个**可能与 165 存储过程不一致**的保管员,比直接失败危险得多。
+
+**④ 纳入 WP6 对账,持续监控不变量。**
+
+新增一条对账项,任一结果非 0 即告警并回到本节重新评估:
+
+```sql
+-- 165 / 本地各跑一次
+SELECT 'ItemMaster' AS src, COUNT(*) AS violations
+  FROM ItemMaster WHERE Domain='8010'
+   AND ItemNum COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%'
+UNION ALL
+SELECT 'EmpWorkDutyMaster', COUNT(*)
+  FROM EmpWorkDutyMaster WHERE Domain='8010'
+   AND (ItemNum1 COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%'
+     OR ItemNum2 COLLATE Latin1_General_BIN2 LIKE '%[^0-9A-Z]%');
+```
+
+#### 被否掉的三个选项及理由
+
+| 选项 | 否掉的理由 |
+|------|-----------|
+| 在 C# 里模拟 `Chinese_PRC_CI_AS` | **做不到可靠**。.NET 5+ 在 Windows 上也走 ICU,而 SQL Server 用自有 Unicode 排序版本(`_90`/`_100`/`_140`);`CompareOptions` 没有任何组合能精确对齐,且两边升级节奏独立。用不可验证的近似换取"看起来一致",是把风险藏起来 |
+| 把比较下推到 SQL Server 执行 | 每次建单都要跨库查 165,正是 §3.3 明确要避免的;且解析将绑定 165 可用性。更要命的是 §3.3 的方向是**迁到本地 MySQL 副本**,那里是 `utf8mb4_unicode_ci` 第四套语义——问题没解决,只是换了个地方 |
+| 维持现状 `OrdinalIgnoreCase` 不动 | 今天能跑对是**因为数据里恰好 0 条小写**,属运气而非设计。约束没被写下来、没被强制、没被监控,下一批主数据进来就可能悄悄错。行为可以保留,工程上必须补②③④ |
+
+#### 附带修掉一个确定性缺陷(同一处代码,建议合并处理)
+
+`KeeperResolveService` 的取数 SQL **没有 `ORDER BY`**,随后 `duties.Find(...)` 取"第一条"匹配。SQL Server 在无 `ORDER BY` 时不保证返回顺序,因此**当多条职责区间重叠时,命中哪个保管员在不同执行间是不确定的**——同一张工单重跑可能得到不同的 `User1`。
+
+这个缺陷与排序规则无关,但触发概率**高于**排序规则问题(职责区间重叠在实际维护中很常见)。修法:
+
+- 取数加 `ORDER BY ItemNum1, ItemNum2, RecID`;
+- 明确重叠策略并写进注释——建议**最窄区间优先**(更具体的职责划分覆盖粗放的兜底行),同宽则按 `RecID` 小者优先。§3.5 的兜底行正是最宽区间,天然排在最后,不会抢走精细分段的命中。
+
 ---
 
 ## 4. 任务三:`MobileTask` 推送服务(复刻 `pr_WMS_BPM_AddMobileTask`)
@@ -246,5 +444,6 @@ APP 侧关闭任务的语义(`pr_WMS_BPM_DelMobileTask`,Ai-DOP **不实现**
 | B2 | 解析不到 `Storer`/`Keeper` 时,任务 `Executer` 为空 | **已查实**:`GetMobileTaskList` 的过滤含 `or t.Executer=''`,空执行人的任务**对全体仓管可见**,不是"无主任务"(见 §4.3.1) | **保留原行为**,可加告警但不得中断推送、不得改成报错 |
 | B3 | `NbrTypeMaster` 是否也接管维护 | 手册称一次配置 | 本轮只读 |
 | B4 | 基础数据双向冲突:若现场仍有人从旧 DOP 后台改这 6 张表 | 会与 Ai-DOP 回写互相覆盖 | **上线前必须停用旧 DOP 后台的对应入口**,否则无法保证一致性 |
+| ~~B5~~ | ~~物料号区间比较口径(四套排序语义不一致)~~ | ~~保管员解析结果可能与 165 存储过程不一致~~ | **已定,见 §3.6**:归一为 `Trim + ToUpperInvariant`,比较用 `Ordinal`;在写入边界强制 `^[0-9A-Z]{1,24}$`;解析时对越界字符集拒绝而非猜测;纳入 WP6 对账监控 |
 
 > **B4 是上线前的组织约定,不是技术问题,但不解决会直接导致数据漂移。**

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

@@ -0,0 +1,235 @@
+# WP9 · 领料建单双路径统一(本库 SM ↔ 165 SM)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 消除「工单下达建本库领料单」与「MES 下达建 165 领料单」两条并存路径,确立**唯一权威库**与**唯一取号源**,避免重号、双单、状态口径分裂 |
+| 编写日期 | 2026-08-09 |
+| 状态 | **待决策**(D7/D8/D9 未定前不得改写路径代码) |
+| 前置 | WP2(165 取号)、WP3(Outbox)、WP4 建领料已落地 |
+| 产出 | 决策记录 + 路径开关 + 单一入口 + 号格式一致性测试 + S5 列表切源 + 盘点/对账 SQL |
+| 不做 | 不改 165 结构;不改 `NbrControl` 配置数据;不引入分布式事务;不动退料(`WOD`)/入库(`WOI`)链 |
+| **开工前必读** | [`00-总体方案.md`](./00-总体方案.md)、[`00B-执行须知(模型输入契约).md`](./00B-执行须知(模型输入契约).md)、[`WP2-单号服务.md`](./WP2-单号服务.md)、[`WP4-回写业务映射与过程复刻.md`](./WP4-回写业务映射与过程复刻.md) |
+
+> **本工作包的由来**:原任务书假定「建领料 = 移植旧 DOP C#,单号走 WP2」,未登记「新 Ai-DOP 自身已有一条本库建单路径」。WP4 落地后两条路径同时存在,必须收敛。
+
+---
+
+## 1. 现状事实(实测,非推断)
+
+### 1.1 两条路径逐项对照
+
+| 维度 | 路径 A:本库下达 | 路径 B:MES 建领料(WP4) |
+|------|------------------|---------------------------|
+| HTTP 入口 | `POST /api/WorkOrder/dispatch/release` | `POST /api/aidop/wms-pick/create` |
+| 编排 | `WorkOrderDispatchService.ReleaseWorkOrder` | `CreatePickBillService.Create` |
+| 建单 | `WorkOrderPickBillService.CreateForWorkOrderAsync` | 同上(内联 `InsertNbrOn165Async`) |
+| 取号 | `NumberRuleService.NextBatchInCurrentTransactionAsync("SM", domain, 1, account)` | `NbrSequenceService.AllocateAsync("sm", n, domain, user)` |
+| **计数器所在库** | **本库 MySQL** `NbrControl` / `NbrDayInfo` | **165** `NbrDayInfo`(`UPDATE…OUTPUT` 原子) |
+| 取号失败降级 | `SM` + `yyyyMMddHHmmss` + 雪花后 5 位 | 无降级,直接抛错 |
+| **单据落库** | **本库 MySQL** `NbrMaster`/`NbrDetail` | **165** `NbrMaster`/`NbrDetail` |
+| 主键 | `SELECT MAX(RecID)+1`(**非并发安全**) | 165 identity,`OUTPUT INSERTED.RecID` |
+| 去重键 | `tenant_id + WorkOrd + Type='SM' + IFNULL(TransType,'')=''`(查本库) | `Domain + Type='SM'` 的 `DISTINCT WorkOrd`(查 165) |
+| 明细聚合 | `GROUP BY ItemNum`,`ItemMaster` LEFT JOIN | 内存按 `ItemNum` 聚合 + `ItemLite` 原生 SQL |
+| 保管员 `User1/User2` | **不写** | 写(`KeeperResolveService`,仅建单时) |
+| 工单头 | 本库 `WorkOrdMaster` 同事务 UPDATE(含 `LotSerial`/`ReleaseDate`/`Eff`/`DueDate` 平移) | 本库 UPDATE `Status`/`Batch`;165 走 Outbox **UPSERT** |
+| 里程碑工序 | 本库 `WorkOrdRouting.Status='r'` | 本库 + 165 直写 |
+| 附带副作用 | `mes_morder.morder_state='下达'`、齐套态更新 | 无 |
+| 事务边界 | 单一 MySQL 本地事务 | 本地 UPDATE + 165 直写 + Outbox,**无跨库事务** |
+| 前端入口 | S1 工单池下达页「工单下达」按钮 | 同页「MES下达建领料」/ 行「MES领料」 |
+
+### 1.2 五个冲突点
+
+| # | 冲突 | 后果 |
+|---|------|------|
+| C1 | **两套计数器互不可见,号格式相同** | 同日两边各发号 → `SM20260809001` 在本库与 165 各指一张不同的单,重号 |
+| C2 | **去重键不互通** | 同一工单可在本库、165 各建一张 SM,且两边都认为「未建过」 |
+| C3 | **`Status='r'` 两边都写** | 工单显示「已下达」,但 165 上可能根本没有领料单,PDA 无活可干 |
+| C4 | **降级号无区分前缀** | 165 不可达时本库降级号 `SM+时间戳`,事后无法判断哪些是降级产物 |
+| C5 | **对账无主键** | WP6 无法用 `Nbr` 对齐两库,差异不可判定 |
+
+### 1.3 已具备的收敛条件(不用新造)
+
+- S5「生产领料单」列表读 `mdp_std_production_issue`,其同步服务 `ProductionIssueMdpSyncService` **已预置双源**:
+  `S5_PRODUCTION_ISSUE_MASTER`(源 `AIDOPDEV_MYSQL`)与 `S5_PRODUCTION_ISSUE_MASTER_SQLSERVER`(源 `DOPDEMORQ_SQLSERVER`,**当前 `status=0` 未启用**),并已有 `RunSourceSwitchFullAsync` 切源全量重建方法(尚未暴露 HTTP 端点)。
+- 因此「权威库切到 165」不需要改 S5 页面,只需切源 + 全量重建。
+
+---
+
+## 2. 决策项(**未定前不得改写路径代码**)
+
+| # | 决策 | 选项 | 建议默认 |
+|---|------|------|----------|
+| **D7** | 领料单的**权威库** | A:165 唯一权威,本库退为只读镜像<br>B:本库唯一权威,Outbox 单向推 165<br>C:双写并对账 | **A** |
+| **D8** | 本库已存量 SM 单如何处理 | A:保留并打标 `source_system='AIDOP_LEGACY'`,不迁移<br>B:按工单在 165 重建<br>C:物理清理 | **A** |
+| **D9** | 下达按钮语义 | A:合并为一个「工单下达」,内部按开关决定写哪边<br>B:保留两个按钮(过渡期)<br>C:MES 按钮仅作补偿重试 | **A**(过渡期先 C) |
+
+**D7 建议 A 的依据**:MES/WMS APP 只认 165 的 `NbrMaster`;本库那张表既不被 APP 读,也不被 165 过程读,仅供 Ai-DOP 自己列表展示,而展示已可切源。若选 B,则 APP 端永远拿不到实时单据,WP5 场景 1 无法通过。
+
+> **D9 与 WP10 卡片 D 有前端交集(2026-08-09 登记)**:WP10 S3 正在改的 `workOrderDispatchList.vue` 就是这两个按钮所在的页面。已要求 S3 把建单结果弹窗做成独立组件,因此 D9 无论落 A/B/C,**弹窗与错误码呈现都可复用,只需换调用点**;返工面限于按钮布局与触发入口。详见 [`WP10`](./WP10-跨库成败判定与失败兜底.md) §6。
+>
+> 决策落定后,把结论回填到 [`WP4-回写业务映射与过程复刻.md`](./WP4-回写业务映射与过程复刻.md) §6 待决策表与 [`附-字段共管矩阵.md`](./附-字段共管矩阵.md) §14。
+
+---
+
+## 3. 目标形态(按 D7-A 展开)
+
+```
+S1 工单池「工单下达」
+        │
+        ├─ 本地事务:WorkOrdMaster(Status/LotSerial/OrdDate/DueDate/ReleaseDate/Batch)
+        │            WorkOrdRouting(里程碑→r)、mes_morder(下达)
+        │
+        ├─ 建领料:IPickBillCreator
+        │     ├─ Mode=Mes   → WP2 取号(165 sm) + 165 直写 NbrMaster/NbrDetail   ← 默认
+        │     └─ Mode=Local → 本库建单,号带独立前缀(降级/离线)
+        │
+        └─ Outbox:165 WorkOrdMaster UPSERT(Status/Batch)
+
+本库 NbrMaster ← 由 WP1 入站抽数从 165 回灌(只读镜像)
+S5 生产领料单列表 ← mdp_std_production_issue ← 源切至 DOPDEMORQ_SQLSERVER
+```
+
+**不变量**
+
+1. 任一时刻,一个工单在**权威库**上最多一张有效 SM 单(`Type='SM'` 且 `IFNULL(TransType,'')=''`)。
+2. 权威库为 165 时,本库 `NbrMaster` **只写不由业务代码产生**,仅由入站同步写。
+3. 降级号必须与 165 号段**前缀可区分**,且降级单不得被当作 165 单对账。
+
+---
+
+## 4. 实施步骤(5 步,每步独立可验收)
+
+### S1 · 现状盘点(先跑数,不改代码)
+
+在两库分别执行,产出 `_wp9_盘点.md`:
+
+```sql
+-- ① 本库 MySQL aidopdev:SM 单存量与号段
+SELECT tenant_id, DATE(CreateTime) AS d, COUNT(*) AS cnt, MIN(Nbr) AS min_nbr, MAX(Nbr) AS max_nbr
+FROM NbrMaster WHERE Type='SM' GROUP BY tenant_id, DATE(CreateTime) ORDER BY d DESC;
+
+-- ② 本库:疑似降级号(非 SM+8位日期+3位序号)
+SELECT Nbr, WorkOrd, CreateTime FROM NbrMaster
+WHERE Type='SM' AND Nbr NOT REGEXP '^SM[0-9]{8}[0-9]{3}$';
+
+-- ③ 本库:一个工单多张 SM
+SELECT WorkOrd, COUNT(*) c FROM NbrMaster
+WHERE Type='SM' AND IFNULL(TransType,'')='' GROUP BY WorkOrd HAVING c>1;
+```
+
+```sql
+-- ④ 165 dopdemorq:SM 单存量
+SELECT Domain, CONVERT(date, CreateTime) d, COUNT(*) cnt, MIN(Nbr) min_nbr, MAX(Nbr) max_nbr
+FROM NbrMaster WHERE Type='SM' GROUP BY Domain, CONVERT(date, CreateTime) ORDER BY d DESC;
+
+-- ⑤ 165:计数器当前值
+SELECT Domain, NbrType, Today, NextValue, MaxValue FROM NbrDayInfo WHERE NbrType IN ('sm','SM');
+```
+
+**重号检测**:把 ①、④ 的 `Nbr` 导出取交集,交集非空即已发生 C1。
+
+**验收**:盘点表含「本库 SM 总数 / 165 SM 总数 / 重号条数 / 降级号条数 / 一单多票条数」五个数字。
+
+---
+
+### S2 · 抽象接口 + 开关(不改默认行为)
+
+| # | 落点 | 动作 |
+|---|------|------|
+| 1 | 新增 `WorkOrder/IPickBillCreator.cs` | 定义 `Task<PickBillResult> CreateAsync(long tenantId, string domain, string workOrd, string account, CancellationToken ct)` |
+| 2 | `WorkOrderPickBillService` | 实现该接口,改名语义为 `LocalPickBillCreator`(保留类名以免影响引用可加 `: IPickBillCreator`) |
+| 3 | `CreatePickBillService` | 抽出单工单方法实现该接口(HTTP 端点保留,改为薄封装) |
+| 4 | 配置 | `AiDOP:PickBill:Mode` = `Local` \| `Mes`,**本步默认 `Local`**(保持现状) |
+| 5 | 降级号 | `LocalPickBillCreator` 的兜底号前缀改 `SMX`,并写 `Remark='本地降级'` |
+| 6 | 去重 | 统一为「先查权威库,再查镜像库」;口径统一到 `Type='SM' AND IFNULL(TransType,'')='' AND IsActive=1` |
+
+**验收**:`Mode=Local` 下 `dispatch/release` 行为与改造前逐字段一致(同工单跑前后各一次,比对 `NbrMaster`/`NbrDetail` 全列)。
+
+---
+
+### S3 · 号格式一致性固化(切换前必须过)
+
+| # | 落点 | 动作 |
+|---|------|------|
+| 1 | `Admin.NET.Plugin.AiDOP.Tests/DataPlatform/` | 新增 `PickBillNumberParityTests`:对同一 `(NbrPre1/2/3, DateType, MaxValue, 序号, 日期)`,断言 `NbrSequenceFormatter` 与 `NumberRuleFormatter` 输出**逐字符相同** |
+| 2 | 同上 | 断言补零宽度都取 `LEN(MaxValue)`(`sm` 的 `MaxValue=999` → 3 位 → `SM20260809001`) |
+| 3 | 差异处理 | 若两者不一致:**不改 165 侧**,只改本库 `NumberRuleFormatter` 向 165 对齐,并在测试里锁死 |
+
+**验收**:测试通过;`SMX` 降级号被断言**不匹配** 165 号正则。
+
+---
+
+### S4 · 切换权威库(按 D7-A)
+
+| # | 落点 | 动作 |
+|---|------|------|
+| 1 | 配置 | `AiDOP:PickBill:Mode = Mes` |
+| 2 | `ReleaseWorkOrder` | 建单调用改走 `IPickBillCreator`;**165 建单失败时整单下达失败并回滚本地事务**(不允许「工单已下达但无领料单」) |
+| 3 | 前置校验 | 下达前校验 165 可达 + `EmpWorkDutyMaster` 能解析出保管员(否则给出明确报错,指向 WP7 §3) |
+| 4 | 幂等 | 重复下达同一工单:查 165 已存在则返回原 `Nbr`,`Created=false`,不再取号 |
+| 5 | 本库 `NbrMaster` | 业务代码停止写入;仅保留入站同步写 |
+
+**验收**:连续两次下达同一工单,165 上只有一张 SM,第二次返回原号;断网 165 时下达报错且本库工单状态未变。
+
+---
+
+### S5 · 展示与镜像收敛
+
+| # | 落点 | 动作 |
+|---|------|------|
+| 1 | `mdp_source_entity` | 启用 `S5_PRODUCTION_ISSUE_MASTER_SQLSERVER`(`status=1`),停用 `S5_PRODUCTION_ISSUE_MASTER` |
+| 2 | `AidopKanbanController` | 暴露切源端点(或一次性调用)`ProductionIssueMdpSyncService.RunSourceSwitchFullAsync("DOPDEMORQ_SQLSERVER", …)` |
+| 3 | WP1 | 确认 `NbrMaster`/`NbrDetail` 入站映射已覆盖 SM 头/明细,作为本库只读镜像 |
+| 4 | 前端 | 按 D9 收敛按钮:合并为「工单下达」,或把「MES领料」降级为补偿入口并加二次确认文案 |
+| 5 | 存量 | 按 D8 给本库历史 SM 打 `source_system='AIDOP_LEGACY'` 标记 |
+
+**验收**:S5 生产领料单列表的 `nbr` 与 165 `NbrMaster` 一致(抽 10 条比对);下达后该页可见新单。
+
+---
+
+## 5. 跨模块影响(改前必须知会)
+
+| 模块 | 影响 | 缓解 |
+|------|------|------|
+| **S1 工单下达**(在用功能) | 建单目标库改变;165 不可达时下达会失败 | 开关可秒切回 `Local`;S4 前置校验给明确报错 |
+| **S2 工单进度看板** | `Status='r'` 语义不变,但「有无领料单」判据从本库改为 165/镜像 | 若有 SQL 直查本库 `NbrMaster`,需随镜像切换复核 |
+| **S5 生产领料单列表** | 数据源切换,切源为 **FULL Replace(破坏性重建)** | 先在非生产跑一次;保留切源前导出 |
+| **WP6 对账** | 对账基准从「两库各自」变为「165 权威 + 镜像一致性」 | 对账口径写入 WP6 |
+| **多租户** | 本库按 `tenant_id` 隔离,165 只有 `Domain` | 需固化 `tenant_id ↔ Domain` 映射,作为 S2 的前置澄清项 |
+
+---
+
+## 6. 回滚
+
+1. `AiDOP:PickBill:Mode` 切回 `Local`,下达恢复本库建单。
+2. 已在 165 建出的单**不回删**(APP 可能已作业);靠 S2 的统一去重避免重复建。
+3. S5 列表切源回 `AIDOPDEV_MYSQL` 并重跑 FULL Replace。
+
+---
+
+## 7. 验收清单(交付前逐条勾)
+
+- [ ] D7 / D8 / D9 已决策并回填 WP4 §6、字段矩阵 §14
+- [ ] S1 盘点五个数字已出,重号条数已知
+- [ ] `IPickBillCreator` 抽象完成,`Mode=Local` 行为与改造前逐字段一致
+- [ ] 号格式一致性测试通过;降级号前缀 `SMX` 与 165 号段不可混淆
+- [ ] `Mode=Mes` 下重复下达幂等,返回原 `Nbr`
+- [ ] 165 不可达时下达失败且本地事务回滚(无「已下达无领料单」)
+- [ ] 保管员解析失败有指向 WP7 §3 的明确报错
+- [ ] S5 列表已切源并与 165 抽样一致
+- [ ] 本库业务代码不再写 `NbrMaster`(全仓检索确认)
+- [ ] 存量本库 SM 已按 D8 处理
+
+---
+
+## 8. 证据索引
+
+| 证据 | 位置 |
+|------|------|
+| 本库建单主体 | `server/Plugins/Admin.NET.Plugin.AiDOP/WorkOrder/WorkOrderPickBillService.cs`(`CreateForWorkOrderAsync` :23、去重 :150、`MAX(RecID)+1` :235) |
+| 本库下达编排 | `.../WorkOrder/WorkOrderDispatchService.cs`(`ReleaseWorkOrder` :176、本地事务 :239) |
+| 本库取号 | `.../Supply/NumberRuleService.cs`(读本库 `NbrControl` :50、`NbrDayInfo` :144) |
+| 165 取号 | `.../DataPlatform/Sequence/NbrSequenceService.cs`(`UPDATE…OUTPUT` :114) |
+| MES 建单 | `.../DataPlatform/Wms/CreatePickBillService.cs`(去重 :74、165 直写 `InsertNbrOn165Async`、工单头 Outbox UPSERT) |
+| S5 列表与双源 | `.../MaterialWarehouse/ProductionIssueService.cs`、`ProductionIssueMdpSyncService.cs`(实体码 :24/:28、切源 :121) |
+| 号段规则实测 | [`WP2-单号服务.md`](./WP2-单号服务.md) §2.1(`sm`:`S`+`M`+`YYYYMMDD`,`MaxValue=999`) |

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

@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+"""WP10 S0 + Card0: reset 165 SM smoke data; widen EmpWorkDutyMaster coverage."""
+import json
+from pathlib import Path
+import pyodbc
+
+def connect():
+    cfg = json.loads((Path.home() / ".cursor" / "mcp.json").read_text(encoding="utf-8"))
+    env = cfg["mcpServers"]["sqlserver-dopdemorq"]["env"]
+    for driver in ("ODBC Driver 18 for SQL Server", "ODBC Driver 17 for SQL Server"):
+        conn_str = (
+            f"DRIVER={{{driver}}};"
+            f"SERVER={env['SQLSERVER_HOST']},{env['SQLSERVER_PORT']};"
+            f"DATABASE={env['SQLSERVER_DATABASE']};"
+            f"UID={env['SQLSERVER_USER']};"
+            f"PWD={env['SQLSERVER_PASSWORD']};"
+            "TrustServerCertificate=yes;"
+        )
+        try:
+            return pyodbc.connect(conn_str, timeout=20)
+        except Exception as ex:
+            last = ex
+    raise last
+
+def main():
+    cn = connect()
+    cn.autocommit = False
+    cur = cn.cursor()
+
+    cur.execute(
+        """
+        SELECT m.Nbr, m.WorkOrd,
+               (SELECT COUNT(*) FROM NbrDetail d WHERE d.NbrRecID=m.RecID) AS dc
+          FROM NbrMaster m WHERE m.Type='SM'
+        """
+    )
+    print("BEFORE_SM", cur.fetchall())
+
+    cur.execute(
+        """
+        DELETE d FROM NbrDetail d
+          JOIN NbrMaster m ON d.NbrRecID = m.RecID
+         WHERE m.Type = 'SM' AND m.Nbr = 'SM20260809003'
+        """
+    )
+    print("del_detail", cur.rowcount)
+    cur.execute("DELETE FROM NbrMaster WHERE Type = 'SM' AND Nbr = 'SM20260809003'")
+    print("del_master", cur.rowcount)
+
+    cur.execute(
+        "SELECT RecID, Employee, ItemNum1, ItemNum2 FROM EmpWorkDutyMaster WHERE Domain='8010' ORDER BY RecID"
+    )
+    print("BEFORE_DUTY", cur.fetchall())
+
+    # Widen upper bound of RecID=19 so letter-prefixed items (A*) are covered
+    cur.execute(
+        """
+        UPDATE EmpWorkDutyMaster
+           SET ItemNum2='ZZZZZZZZZZZZZZZZZZZZZZZZ', UpdateUser='aidop', UpdateTime=GETDATE()
+         WHERE RecID=19 AND Domain='8010'
+        """
+    )
+    print("widen_19", cur.rowcount)
+
+    cn.commit()
+
+    cur.execute("SELECT m.Nbr FROM NbrMaster m WHERE m.Type='SM'")
+    print("AFTER_SM", cur.fetchall())
+    cur.execute(
+        "SELECT RecID, Employee, ItemNum1, ItemNum2, Location, Duty FROM EmpWorkDutyMaster WHERE Domain='8010' ORDER BY RecID"
+    )
+    print("AFTER_DUTY", cur.fetchall())
+    cn.close()
+    print("OK_165")
+
+if __name__ == "__main__":
+    main()

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

@@ -1,6 +1,6 @@
 # 执行进度(2026-08-09)
 
-后端版本目标 **1.0.301** / 前端 **2.4.276**(本轮收尾提交)。
+后端版本目标 **1.0.302** / 前端 **2.4.277**(WP10 卡片 0/A/B/C/D 落地)。
 
 ## 已完成(含本轮收尾)
 
@@ -11,6 +11,33 @@
 | **D1** | 本动作**不写** Routing `C→W` | 仅 MilestoneOp 且非 C → `r` |
 | **WP7 §2 前端** | S5 仓储下 6 页:`/aidop/s5/wms-base/*`,`FUNC-S5-020`…`025`,调用 `wms-base/page|upsert` | 菜单种子 + `wmsBaseMasterList.vue` |
 | **下达挂接** | S1 工单池下达:工具栏「MES下达建领料」+ 行「MES领料」→ `wms-pick/create` | `workOrderDispatchList.vue` |
+| **WP10 卡片 0** | 165 `EmpWorkDutyMaster` RecID=19 上界扩至 `ZZZZ…`,覆盖字母物料号 | 联调 SQL(`tools/_wp10_s0_card0.py`) |
+| **WP10 S0** | 清 `SM20260809003`;本库 `mdp_outbox` 清空;`M500000005` 复位 `Status=p` | 165 + aidopdev |
+| **WP10 S1+S2** | 165 事务、幂等三分类、重放收敛、本库+Outbox 原子、业务幂等键、工序 Outbox、统一返回契约、D11-A、`NO_KEEPER`/`KEEPER_RULE_UNSAFE` | `CreatePickBillService.cs` |
+| **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** | 无分布式事务下的跨库成败判定与失败兜底:13 项缺陷、D10/D11 已定、统一返回契约与错误码、**5 张派发卡片**([`WP10 §12`](./WP10-跨库成败判定与失败兜底.md)) |
+| **WP7 §3.4** | `KeeperResolveService` 与 §3.1/§3.2 原始逻辑的 **7 项实现差距**逐条对照 |
+| **WP7 §3.5** | `EmpWorkDutyMaster` 联调补数派发卡片(= WP10 **卡片 0**):实测表结构、可用基础数据、API/SQL 两种做法、验收表 |
+| **WP7 §3.6(决策 B5,已定)** | 物料号区间比较口径:归一 `Trim + ToUpperInvariant` 后用 `Ordinal`;写入边界强制 `^[0-9A-Z]{1,24}$`;越界字符集拒绝而非猜测;纳入 WP6 对账 |
+| **WP6 §2.5** | 新增「物料号字符集不变量」每日对账项 |
+| 错误码扩充 | WP10 §4.3 新增 `KEEPER_RULE_UNSAFE` |
+
+### 本轮实测发现(均为实查,非推断)
+
+| # | 发现 | 影响 |
+|---|------|------|
+| 1 | **`EmpWorkDutyMaster` 为 0 行的后果此前被描述错了**:`KeeperResolveService` 未命中职责时只 `continue` 跳过,**不中断建单** | 单子照建、`User1` 为空,属**静默错单**,比"建不出来"更难发现。README 阻断项已改正 |
+| 2 | **`NO_KEEPER` 目前是死代码** | WP7 §3.2 两条硬校验未实现,该错误码不可能被触发;卡片 D 须连校验一起做 |
+| 3 | **区间比较存在四套排序语义**:165 `Chinese_PRC_CI_AS` / 旧 DOP C# `CurrentCulture`(大小写敏感)/ 现 `OrdinalIgnoreCase` / 本地 MySQL `utf8mb4_unicode_ci` | 已由 B5 收敛。旧 DOP 那套**最差**(大小写敏感 + 随 locale 漂移),明确**不作为**对齐基准 |
+| 4 | **Domain 8010 的 16,150 个物料号字符集恰好是 `[0-9A-Z]`**(零小写/连字符/空格),四套语义在此子集上完全重合;Domain 2410 有 2 条违例(含中文与连字符) | B5 成立的实测依据;同时说明该前提**必须强制并监控**,不能当作既定事实 |
+| 5 | `KeeperResolveService` 取数 SQL **无 `ORDER BY`**,`Find()` 取首条 | 职责区间重叠时命中的保管员**不确定**,同单重跑可能得到不同 `User1`。触发概率高于排序规则问题,已并入卡片 D |
 
 ## API
 
@@ -22,11 +49,13 @@
 
 | 项 | 原因 |
 |----|------|
+| **物料职责补数(`EmpWorkDutyMaster` 165 上 0 行)** | **已解阻断,可立即派发**:卡片见 [`WP7 §3.5`](./WP7-基础数据与任务推送.md)(= WP10 卡片 0),无任何代码依赖,应最先执行。不补则成功路径永远跑不通(建单会成功但保管员为空) |
 | MobileTask(A4) | 待现场确认 PDA 是否真有「待发料」 |
 | 外协 PW(D3)、删周计划(D4)、退料(D6) | 决策未定 |
 | WP5 E2E + 真人 APP | 需现场环境 |
 | WP6 对账 | 依赖 WP5 |
-| 与本库 `WorkOrderPickBillService`(MySQL SM)统一单号 | 本轮保留双路径:本库下达仍走 `NumberRule`;MES 侧走 WP2 `sm` |
+| 与本库 `WorkOrderPickBillService`(MySQL SM)统一单号 | 本轮保留双路径:本库下达仍走 `NumberRule`;MES 侧走 WP2 `sm`。收敛方案见 [`WP9-领料建单双路径统一.md`](./WP9-领料建单双路径统一.md),**受 D7/D8/D9 阻断**(其 S1 盘点步可先行) |
+| 跨库成败判定与失败兜底 | 复查发现 13 项缺陷并已实测验证:工单 `M500000005` 处于卡死态(本库已下达、165 有领料单但无工单头、回写消息成死信、重放被幂等跳过),`mdp_outbox` 8 行中 4 条死信无人知晓。方案见 [`WP10-跨库成败判定与失败兜底.md`](./WP10-跨库成败判定与失败兜底.md)。**D10、D11 已定 A(2026-08-09),S0/S1/S2/S3/S4a 均可派发**(派发前先读其 §11 已知空白,卡片见 §12);仅 S4b 受 D12 阻断 |
 
 ## 菜单生效说明
 

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

@@ -293,6 +293,19 @@
 | 动作 | 表.列(允许) | 明确禁止 |
 |------|---------------|----------|
 | 建领料 `CreatePickBill` | `WorkOrdMaster.Status→r`,`Batch`;里程碑 `WorkOrdRouting.Status→r`;`NbrMaster`/`NbrDetail` 整行 INSERT(含 User1/2 **仅此次**) | 之后 UPDATE 带 `User1`/`User2`;写 `QtyRec`/`IsConfirm` |
+
+### 13.1 建领料的回写通道([`WP10`](./WP10-跨库成败判定与失败兜底.md) S1 后)
+
+白名单**列**不变,但**写入通道**有调整,对账与故障处置需按此判断:
+
+| 目标 | 通道 | 幂等键 |
+|------|------|--------|
+| 165 `NbrMaster` / `NbrDetail` | **165 直写**,包在 165 本地事务内(需 identity `RecID` 回填明细) | 无(靠建单前的三分类探查去重) |
+| 165 `WorkOrdMaster.Status`/`Batch` | **Outbox UPSERT**(`action_code=PICK_WOM_UPSERT`) | `pick\|{domain}\|{workOrd}\|wom` |
+| 165 里程碑 `WorkOrdRouting.Status` | **Outbox UPDATE,逐工序一条**(原为裸直写,WP10 F8 改造)。执行器按全 keys 等值匹配,故不能一条消息覆盖多工序。keys = `{Domain, WorkOrd, OP}`(**工序列名是 `OP`、int**;唯一索引虽含 `ID`,但该列 Ai-DOP 不写、值为 NULL,**不得入 keys**) | `pick\|{domain}\|{workOrd}\|{op}\|wor` |
+| 本库 `WorkOrdMaster` / `WorkOrdRouting` | MySQL 本地事务,与 Outbox 入队同事务 | — |
+
+> 三条 165 通道**均无跨库事务**。判断「该列是否已生效」时,Outbox 通道要多看一步 `mdp_outbox.status`;直写通道则以 165 现值为准。
 | 调领料日 `AdjustNbrDate` | `NbrMaster.Date`,`EffDate`(仅 `Status` 空) | 已 `C` 的单 |
 | 改工单量/优先级 | `WorkOrdMaster.QtyOrded/LotSerial/Priority/OrdDate/DueDate`;`WorkOrdRouting.QtyOrded`;`WorkOrdDetail.QtyRequired`;条件清空 `Nbr*.Status` 并改 `QtyOrd`/`CurrQtyOpened` | `QtyCompleted`;**D1 未决前**勿写 `WorkOrdRouting.Status C→W` |
 | 排产 | `PeriodSequenceDet` CRUD;`ScheduleResultOpMaster` CRUD;可调 `WorkOrdMaster.OrdDate` | `CompQty`/`ProdQty` |
@@ -310,6 +323,7 @@
 | D3 | 委外批次过程是否复刻 | §8 整表 UPDATE 范围 |
 | D4 | 删周计划/重排产前置状态 | §9/§11 DELETE |
 | D6 | 退料谁建单 | 若 Ai-DOP 接管,将新增 `Nbr*` Type=`WOD` 行(本表未展开) |
+| D12 | Outbox 退避与告警参数 | 不改白名单,但影响「回写多久算失败」的判定;见 [`WP10`](./WP10-跨库成败判定与失败兜底.md) §3 |
 | 现场抽样 | 空库无法验证真实 Status 分布 | 各「中/低」列升「高」的前置 |
 | 大小写 | `p/P`、`r/R` 混用 | 回写与 `expect` 建议大小写不敏感 |
 

+ 4 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpOutboxEnqueueService.cs

@@ -15,9 +15,10 @@ public sealed class MdpOutboxEnqueueService : ITransient
     }
 
     /// <summary>
-    /// 幂等入队:已存在 status∈(0,1) 的同 idem_key 则跳过。入队成功后 Pulse。
+    /// 幂等入队:已存在 status∈(0,1) 的同 idem_key 则跳过。
     /// </summary>
-    public async Task<bool> TryEnqueueAsync(MdpOutbox item, CancellationToken ct = default)
+    /// <param name="pulse">为 false 时不立即 Pulse(供调用方在本地事务提交后再唤醒,见 WP10 S1)。默认 true 保持既有行为。</param>
+    public async Task<bool> TryEnqueueAsync(MdpOutbox item, CancellationToken ct = default, bool pulse = true)
     {
         if (item == null) throw new ArgumentNullException(nameof(item));
         if (string.IsNullOrWhiteSpace(item.IdemKey))
@@ -33,7 +34,7 @@ public sealed class MdpOutboxEnqueueService : ITransient
         item.CreateTime = DateTime.Now;
         item.UpdateTime = item.CreateTime;
         await _db.Insertable(item).ExecuteCommandAsync(ct);
-        _wake.Pulse();
+        if (pulse) _wake.Pulse();
         return true;
     }
 }

+ 52 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpTargetPushDispatcher.cs

@@ -54,15 +54,25 @@ public sealed class MdpTargetPushDispatcher : ITransient
 
                 if (source == null)
                 {
-                    await MarkAsync(item, 2, null, $"目标源 {item.TargetSourceCode} 未启用", cancellationToken);
-                    failed++;
+                    // 配置类失败:保持 pending,不烧 retry_count,便于配置修复后被 60s 兜底作业捡起
+                    await MarkConfigIssueAsync(
+                        item,
+                        $"目标源 {item.TargetSourceCode} 未启用",
+                        cancellationToken);
+                    skipped++;
+                    continue;
+                }
+
+                if (!TryResolve(source, out var executor, out var routeError))
+                {
+                    await MarkConfigIssueAsync(item, routeError!, cancellationToken);
+                    skipped++;
                     continue;
                 }
 
-                var executor = Resolve(source);
                 _logger.LogInformation(
                     "[MdpTargetPushDispatcher] outbox id={Id} source={Source} type={SourceType} via={Executor}",
-                    item.Id, source.SourceCode, source.SourceType, executor.SupportedType);
+                    item.Id, source.SourceCode, source.SourceType, executor!.SupportedType);
                 var result = await executor.PushAsync(source, item, cancellationToken);
                 if (result.Success)
                 {
@@ -90,17 +100,49 @@ public sealed class MdpTargetPushDispatcher : ITransient
         return (success, failed, skipped);
     }
 
-    private IMdpTargetPushExecutor Resolve(MdpSource source)
+    /// <summary>
+    /// 按 source_type / 连接信息解析执行器;无法解析时返回可诊断错误(不再静默落到 API)。
+    /// </summary>
+    private bool TryResolve(MdpSource source, out IMdpTargetPushExecutor? executor, out string? errorMessage)
     {
         var t = (source.SourceType ?? "").Trim().ToUpperInvariant();
         if (t is "DB" or "SQLSERVER" or "MYSQL" or "DATABASE")
-            return _dbPush;
+        {
+            executor = _dbPush;
+            errorMessage = null;
+            return true;
+        }
         if (t == "API" || t == "HTTP")
-            return _api;
-        // 未标注类型时:有库连接信息则走 DB,否则走 API(兼容历史 HTTP 源)
+        {
+            executor = _api;
+            errorMessage = null;
+            return true;
+        }
+        // 未标注类型时:有库连接信息则走 DB,否则不再猜 API
         if (!string.IsNullOrWhiteSpace(source.DbHost) && !string.IsNullOrWhiteSpace(source.DbName))
-            return _dbPush;
-        return _api;
+        {
+            executor = _dbPush;
+            errorMessage = null;
+            return true;
+        }
+
+        _logger.LogWarning(
+            "[MdpTargetPushDispatcher] 路由未解析 source={Code} source_type='{Type}' DbHost='{Host}' DbName='{Name}' ApiBaseUrl='{Api}'",
+            source.SourceCode, source.SourceType, source.DbHost, source.DbName, source.ApiBaseUrl);
+        executor = null;
+        errorMessage =
+            $"SOURCE_ROUTE_UNRESOLVED: 源 {source.SourceCode} 既未标注 source_type,也无 DbHost/DbName";
+        return false;
+    }
+
+    /// <summary>配置类问题:仅写 ErrorMsg/UpdateTime,status 保持 0,retry_count 不变。</summary>
+    private async Task MarkConfigIssueAsync(MdpOutbox item, string error, CancellationToken ct)
+    {
+        item.ErrorMsg = Truncate(error, 900);
+        item.UpdateTime = DateTime.Now;
+        await _db.Updateable(item)
+            .UpdateColumns(x => new { x.ErrorMsg, x.UpdateTime })
+            .ExecuteCommandAsync(ct);
     }
 
     private async Task MarkAsync(MdpOutbox item, int status, string? responseJson, string? error, CancellationToken ct)

+ 160 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpOutboxAdminService.cs

@@ -0,0 +1,160 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+/// <summary>
+/// WP10 S4a · Outbox 可观测与手工重推(本库运维数据,需登录;不加 AllowAnonymous)。
+/// </summary>
+[ApiDescriptionSettings(Order = 328, Description = "出站回写队列")]
+[Route("api/aidop/mdp-outbox")]
+[NonUnify]
+public class MdpOutboxAdminService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly MdpOutboxWakeSignal _wake;
+
+    public MdpOutboxAdminService(ISqlSugarClient db, MdpOutboxWakeSignal wake)
+    {
+        _db = db;
+        _wake = wake;
+    }
+
+    public sealed class PageInput
+    {
+        public int? Status { get; set; }
+        public string? TargetSourceCode { get; set; }
+        public string? ActionCode { get; set; }
+        public string? IdemKey { get; set; }
+        public DateTime? CreateTimeFrom { get; set; }
+        public DateTime? CreateTimeTo { get; set; }
+        public int Page { get; set; } = 1;
+        public int PageSize { get; set; } = 20;
+    }
+
+    public sealed class RetryInput
+    {
+        public long[]? Ids { get; set; }
+        public bool? AllDead { get; set; }
+    }
+
+    [DisplayName("出站回写队列分页")]
+    [HttpGet("page")]
+    public async Task<object> Page([FromQuery] PageInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : Math.Min(input.PageSize, 200);
+
+        var q = _db.Queryable<MdpOutbox>()
+            .WhereIF(input.Status is 0 or 1 or 2, x => x.Status == input.Status!.Value)
+            .WhereIF(!string.IsNullOrWhiteSpace(input.TargetSourceCode),
+                x => x.TargetSourceCode == input.TargetSourceCode!.Trim())
+            .WhereIF(!string.IsNullOrWhiteSpace(input.ActionCode),
+                x => x.ActionCode == input.ActionCode!.Trim())
+            .WhereIF(!string.IsNullOrWhiteSpace(input.IdemKey),
+                x => x.IdemKey.Contains(input.IdemKey!.Trim()))
+            .WhereIF(input.CreateTimeFrom.HasValue, x => x.CreateTime >= input.CreateTimeFrom!.Value)
+            .WhereIF(input.CreateTimeTo.HasValue, x => x.CreateTime <= input.CreateTimeTo!.Value);
+
+        RefAsync<int> total = 0;
+        var rows = await q.OrderByDescending(x => x.Id)
+            .ToPageListAsync(page, pageSize, total);
+
+        return new
+        {
+            total = total.Value,
+            page,
+            pageSize,
+            list = rows.Select(x => new
+            {
+                id = x.Id,
+                tenantId = x.TenantId,
+                targetSourceCode = x.TargetSourceCode,
+                actionCode = x.ActionCode,
+                idemKey = x.IdemKey,
+                payloadJson = x.PayloadJson,
+                status = x.Status,
+                retryCount = x.RetryCount,
+                responseJson = x.ResponseJson,
+                errorMsg = x.ErrorMsg,
+                createTime = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),
+                updateTime = x.UpdateTime.ToString("yyyy-MM-dd HH:mm:ss"),
+            })
+        };
+    }
+
+    [DisplayName("出站回写队列统计")]
+    [HttpGet("stats")]
+    public async Task<object> Stats()
+    {
+        var pending = await _db.Queryable<MdpOutbox>().CountAsync(x => x.Status == 0);
+        var success = await _db.Queryable<MdpOutbox>().CountAsync(x => x.Status == 1);
+        var dead = await _db.Queryable<MdpOutbox>().CountAsync(x => x.Status == 2);
+
+        var since = DateTime.Now.AddHours(-24);
+        var deadLast24h = await _db.Queryable<MdpOutbox>()
+            .CountAsync(x => x.Status == 2 && x.UpdateTime >= since);
+
+        double oldestPendingMinutes = 0;
+        if (pending > 0)
+        {
+            var oldest = await _db.Queryable<MdpOutbox>()
+                .Where(x => x.Status == 0)
+                .OrderBy(x => x.CreateTime)
+                .Select(x => x.CreateTime)
+                .FirstAsync();
+            oldestPendingMinutes = Math.Max(0, (DateTime.Now - oldest).TotalMinutes);
+            oldestPendingMinutes = Math.Round(oldestPendingMinutes, 1);
+        }
+
+        return new
+        {
+            pending,
+            success,
+            dead,
+            oldestPendingMinutes,
+            deadLast24h,
+        };
+    }
+
+    [DisplayName("出站回写手工重推")]
+    [HttpPost("retry")]
+    public async Task<object> Retry([FromBody] RetryInput input, CancellationToken ct = default)
+    {
+        if (input == null)
+            throw Oops.Oh("body 不能为空");
+
+        var hasIds = input.Ids is { Length: > 0 };
+        var allDead = input.AllDead == true;
+        if (!hasIds && !allDead)
+            throw Oops.Oh("请指定 ids 或 allDead=true,禁止全表重置");
+
+        int reset;
+        if (allDead)
+        {
+            reset = await _db.Updateable<MdpOutbox>()
+                .SetColumns(x => x.Status == 0)
+                .SetColumns(x => x.RetryCount == 0)
+                .SetColumns(x => x.ErrorMsg == null)
+                .SetColumns(x => x.UpdateTime == DateTime.Now)
+                .Where(x => x.Status == 2)
+                .ExecuteCommandAsync(ct);
+        }
+        else
+        {
+            var ids = input.Ids!.Distinct().ToArray();
+            reset = await _db.Updateable<MdpOutbox>()
+                .SetColumns(x => x.Status == 0)
+                .SetColumns(x => x.RetryCount == 0)
+                .SetColumns(x => x.ErrorMsg == null)
+                .SetColumns(x => x.UpdateTime == DateTime.Now)
+                .Where(x => ids.Contains(x.Id))
+                .ExecuteCommandAsync(ct);
+        }
+
+        if (reset > 0)
+            _wake.Pulse();
+
+        return new { reset };
+    }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 682 - 214
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/CreatePickBillService.cs


+ 82 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/KeeperResolveService.cs

@@ -1,13 +1,17 @@
+using System.Text.RegularExpressions;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 
 /// <summary>
 /// 领料保管员解析。移植基准:旧 DOP ResourceExamineAppService.GenerateNbr
-/// (按 EmpWorkDutyMaster.ItemNum1..ItemNum2 字符串区间匹配 Employee;非存储过程 Bonded 分支)。
+/// (按 EmpWorkDutyMaster.ItemNum1..ItemNum2 字符串区间匹配 Employee)。
+/// 比较口径见 WP7 §3.6(B5):Trim + ToUpperInvariant 后 Ordinal;最窄区间优先。
 /// </summary>
 public sealed class KeeperResolveService : ITransient
 {
+    private static readonly Regex SafeItemNum = new(@"^[0-9A-Z]+$", RegexOptions.Compiled);
+
     private readonly MdpSourceScopeFactory _scopeFactory;
 
     public KeeperResolveService(MdpSourceScopeFactory scopeFactory)
@@ -23,41 +27,98 @@ public sealed class KeeperResolveService : ITransient
         public string? Location { get; set; }
     }
 
+    public sealed class KeeperResolveResult
+    {
+        public IReadOnlyList<KeeperHit> Hits { get; init; } = Array.Empty<KeeperHit>();
+        /// <summary>未匹配到职责的物料号</summary>
+        public IReadOnlyList<string> MissingItems { get; init; } = Array.Empty<string>();
+        /// <summary>命中但 Location 为空的物料号</summary>
+        public IReadOnlyList<string> EmptyLocationItems { get; init; } = Array.Empty<string>();
+        /// <summary>字符集越界(物料号或职责区间)</summary>
+        public IReadOnlyList<string> UnsafeItems { get; init; } = Array.Empty<string>();
+        public bool Ok => MissingItems.Count == 0 && EmptyLocationItems.Count == 0 && UnsafeItems.Count == 0;
+    }
+
     /// <summary>
-    /// 按物料号匹配职责;返回命中列表。未命中不抛错(旧 C# 仅跳过收集 User1)。
-    /// 若业务要求「未设置保管员则中断建单」,由调用方检查空命中。
+    /// 按物料号匹配职责;返回命中与 WP7 §3.2 硬校验 / §3.6 字符集校验结果。
     /// </summary>
-    public async Task<IReadOnlyList<KeeperHit>> ResolveByItemsAsync(
+    public async Task<KeeperResolveResult> ResolveByItemsAsync(
         string domain, IEnumerable<string> itemNums, CancellationToken ct = default)
     {
         var items = itemNums.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
-        if (items.Count == 0) return Array.Empty<KeeperHit>();
+        if (items.Count == 0)
+            return new KeeperResolveResult();
 
         var db = await _scopeFactory.GetScopeAsync("DOPDEMORQ_SQLSERVER", ct);
         var duties = await db.Ado.SqlQueryAsync<DutyRow>(
             @"SELECT RecID, Employee, ItemNum1, ItemNum2, Location
                 FROM EmpWorkDutyMaster
-               WHERE Domain=@d",
+               WHERE Domain=@d
+               ORDER BY ItemNum1, ItemNum2, RecID",
             new SugarParameter("@d", domain));
 
         var hits = new List<KeeperHit>();
-        foreach (var item in items)
+        var missing = new List<string>();
+        var emptyLoc = new List<string>();
+        var unsafeItems = new List<string>();
+
+        var hasUnsafeDuty = duties.Any(s => !IsSafe(Norm(s.ItemNum1)) || !IsSafe(Norm(s.ItemNum2)));
+
+        foreach (var raw in items)
         {
-            // 与旧 C# 一致:0 >= Compare(ItemNum1, item) && 0 >= Compare(item, ItemNum2)
-            var ewdm = duties.Find(s =>
-                string.Compare(s.ItemNum1, item, StringComparison.OrdinalIgnoreCase) <= 0
-                && string.Compare(item, s.ItemNum2, StringComparison.OrdinalIgnoreCase) <= 0);
-            if (ewdm == null || string.IsNullOrEmpty(ewdm.Employee))
+            var key = Norm(raw);
+            if (!IsSafe(key))
+            {
+                unsafeItems.Add(raw);
+                continue;
+            }
+
+            // 最窄区间优先:下界最大、上界最小、同宽 RecID 小者
+            var match = duties
+                .Select(d => (Row: d, A: Norm(d.ItemNum1), B: Norm(d.ItemNum2)))
+                .Where(d => IsSafe(d.A) && IsSafe(d.B)
+                            && string.Compare(d.A, key, StringComparison.Ordinal) <= 0
+                            && string.Compare(key, d.B, StringComparison.Ordinal) <= 0)
+                .OrderByDescending(d => d.A, StringComparer.Ordinal)
+                .ThenBy(d => d.B, StringComparer.Ordinal)
+                .ThenBy(d => d.Row.RecID)
+                .Select(d => d.Row)
+                .FirstOrDefault();
+
+            if (match == null)
+            {
+                if (hasUnsafeDuty) unsafeItems.Add(raw);
+                else missing.Add(raw);
                 continue;
+            }
+
+            if (string.IsNullOrWhiteSpace(match.Employee))
+            {
+                missing.Add(raw);
+                continue;
+            }
+            if (string.IsNullOrWhiteSpace(match.Location))
+            {
+                emptyLoc.Add(raw);
+                continue;
+            }
+
             hits.Add(new KeeperHit
             {
-                ItemNum = item,
-                DutyRecID = ewdm.RecID,
-                Employee = ewdm.Employee!,
-                Location = ewdm.Location
+                ItemNum = raw,
+                DutyRecID = match.RecID,
+                Employee = match.Employee!,
+                Location = match.Location
             });
         }
-        return hits;
+
+        return new KeeperResolveResult
+        {
+            Hits = hits,
+            MissingItems = missing,
+            EmptyLocationItems = emptyLoc,
+            UnsafeItems = unsafeItems
+        };
     }
 
     /// <summary>拼 NbrMaster.User1(逗号)与 User2(JSON 数组字符串),与旧 DOP 一致。</summary>
@@ -70,6 +131,10 @@ public sealed class KeeperResolveService : ITransient
         return (user1, user2);
     }
 
+    internal static string Norm(string? s) => (s ?? string.Empty).Trim().ToUpperInvariant();
+
+    private static bool IsSafe(string s) => SafeItemNum.IsMatch(s);
+
     private sealed class DutyRow
     {
         public int RecID { get; set; }

+ 30 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/WmsBaseDataService.cs

@@ -86,6 +86,7 @@ public class WmsBaseDataService : IDynamicApiController, ITransient
         if (input.Row == null || input.Row.Count == 0) throw Oops.Oh("row 不能为空");
 
         var row = NormalizeRow(input.Table, input.Row);
+        ValidateEmpWorkDutyRow(input.Table, row);
         var keys = ExtractKeys(input.Table, row);
         if (keys.Count == 0) throw Oops.Oh("缺少业务键");
 
@@ -120,7 +121,8 @@ public class WmsBaseDataService : IDynamicApiController, ITransient
         var json = JsonSerializer.Serialize(payload);
         var keyParts = string.Join("|", keys.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
             .Select(kv => $"{kv.Key}={kv.Value}"));
-        var idem = $"wmsbase|{input.Table}|{keyParts}|{StableHash(json)}";
+        // 业务键幂等:去掉 payload 哈希尾巴,同业务对象重复 upsert 命中同一 idem_key(WP10 S1 / F5)
+        var idem = $"wmsbase|{input.Table}|{keyParts}";
 
         var item = new MdpOutbox
         {
@@ -174,9 +176,36 @@ public class WmsBaseDataService : IDynamicApiController, ITransient
 
         if (!result.ContainsKey("UpdateTime"))
             result["UpdateTime"] = DateTime.Now;
+
+        // WP7 §3.6:职责区间边界归一
+        if (string.Equals(table, "EmpWorkDutyMaster", StringComparison.OrdinalIgnoreCase))
+        {
+            foreach (var col in new[] { "ItemNum1", "ItemNum2" })
+            {
+                if (result.TryGetValue(col, out var v) && v is string s)
+                    result[col] = s.Trim().ToUpperInvariant();
+            }
+        }
         return result;
     }
 
+    /// <summary>WP7 §3.6 B5:强制 ItemNum 区间字符集与方向。</summary>
+    private static void ValidateEmpWorkDutyRow(string table, Dictionary<string, object?> row)
+    {
+        if (!string.Equals(table, "EmpWorkDutyMaster", StringComparison.OrdinalIgnoreCase)) return;
+        static string Norm(object? v) => (v?.ToString() ?? "").Trim().ToUpperInvariant();
+        var a = Norm(row.GetValueOrDefault("ItemNum1"));
+        var b = Norm(row.GetValueOrDefault("ItemNum2"));
+        if (a.Length is < 1 or > 24 || b.Length is < 1 or > 24
+            || !System.Text.RegularExpressions.Regex.IsMatch(a, "^[0-9A-Z]+$")
+            || !System.Text.RegularExpressions.Regex.IsMatch(b, "^[0-9A-Z]+$"))
+            throw Oops.Oh("EmpWorkDutyMaster.ItemNum1/ItemNum2 须为 1–24 位 [0-9A-Z](已自动 Trim/大写)");
+        if (string.Compare(a, b, StringComparison.Ordinal) > 0)
+            throw Oops.Oh("EmpWorkDutyMaster 区间反向:ItemNum1 不得大于 ItemNum2");
+        row["ItemNum1"] = a;
+        row["ItemNum2"] = b;
+    }
+
     private static Dictionary<string, object?> ExtractKeys(string table, Dictionary<string, object?> row)
     {
         var keys = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);

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

@@ -305,6 +305,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (1320990000404L, "/aidop/data-platform/sync-tasks", "aidopDataPlatformSyncTasks", "同步配置中心", "/aidop/data-platform/syncTasks", "ele-Operation", 40, "外部系统标准化接入、同步任务、字段映射、参数公式、调度策略与运行追踪"),
             (1320990000405L, "/aidop/data-platform/sync-logs", "aidopDataPlatformSyncLogs", "数据任务日志", "/aidop/data-platform/syncLogs", "ele-Tickets", 50, "数据任务批次日志与异常样本入口"),
             (1320990000407L, "/aidop/data-platform/action-run-logs", "aidopDataPlatformActionRunLogs", "业务动作日志", "/aidop/data-platform/actionRunLogs", "ele-Document", 55, "订单评审、排程、下达、采购闭环等动作运行日志"),
+            (1320990000408L, "/aidop/data-platform/outbox", "aidopDataPlatformOutbox", "出站回写队列", "/aidop/data-platform/outbox", "ele-Upload", 57, "Outbox 死信监控与手工重推"),
             (1320990000406L, "/aidop/data-platform/mdp-monitor", "aidopDataPlatformMdpMonitor", "MDP运行监控", "/aidop/data-platform/mdpMonitor", "ele-Monitor", 60, "统一查看 S1/S3 等模块 MDP 同步与转换运行状态"),
         };
 

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff