Sfoglia il codice sorgente

feat(S5): IQC 全链路打通(认领→检验单→合格判定→QcCheck 上架)并修热回读贴源缺口

- 热回读 UPSERT 字段守护:165 回读不再用 NULL 抹掉 Ai-DOP 自有字段(jyfzr/yxj 等),状态字段只前进;快照指纹改为序无关,避免行序抖动引发无谓重放
- 回写执行器新增 op=PROC,IQC 判定合格后按 QcCheck 调 165 存储过程完成上架过账
- 补建 IQC 贴源表 mdp_stg_iqc_pull(1.0.335)并把 biz_key_expr 修正脚本(1.0.336)登记进 csproj:此前实体已启用但贴源表缺失,检验单一出现即让整批 HotWatch poll 抛异常;该脚本原取号 1.0.332 与远端 S0 撞号,故改号
- 前端 S5 检验任务/结果页支持认领、指派、优先级、生成检验单与结果提交
- 文档:IQC 链路任务书、QcCheck 上架任务书、热回读字段归属修复任务书,以及 08-11 IQC 端到端留证(含三则环境级事故与脚本撞号处置)

chore: bump version Web 2.4.286 / server 1.0.336
YY968XX 1 mese fa
parent
commit
7dbbe3e8da
39 ha cambiato i file con 5223 aggiunte e 300 eliminazioni
  1. 1 1
      Web/package.json
  2. 60 0
      Web/src/views/aidop/s5/api/iqcTaskEntry.ts
  3. 61 64
      Web/src/views/aidop/s5/iqc/iqcResultList.vue
  4. 322 122
      Web/src/views/aidop/s5/iqc/iqcTaskList.vue
  5. 4 1
      doc/README.md
  6. 459 0
      doc/plan/S5-IQC合格上架过账(QcCheck)执行任务书.md
  7. 398 0
      doc/plan/S5-IQC热回读覆盖本地自有字段修复执行任务书.md
  8. 728 0
      doc/plan/S5来料检验IQC链路打通方案与执行任务书.md
  9. 345 0
      doc/plan/UAT多租户数据方案/README.md
  10. 247 0
      doc/plan/UAT多租户数据方案/逐表清单.md
  11. 1 0
      doc/plan/UAT留证/2026-08-09-S1起真人测试/screenshots/.gitkeep
  12. 53 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/00-基线快照.md
  13. 46 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/03-P1P2P3运行验收.md
  14. 119 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md
  15. 51 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/20-认领被回读覆盖修复验收.md
  16. 118 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/30-多实例抢同库热回读事故.md
  17. 34 0
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/README.md
  18. BIN
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2259-生成检验单-IQC202608110002.png
  19. BIN
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2306-提交检验结果合格-IQC202608110002.png
  20. BIN
      doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2339-主管审核通过关单-IQC202608110002.png
  21. 3 1
      doc/plan/旧DOP-MES-WMS/对接任务书/00-总体方案.md
  22. 24 3
      doc/plan/旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md
  23. 1 0
      doc/plan/旧DOP-MES-WMS/对接任务书/README.md
  24. 1 1
      doc/plan/旧DOP-MES-WMS/对接任务书/WP5-主链路端到端验证.md
  25. 1 0
      doc/plan/旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md
  26. 72 0
      doc/plan/旧DOP-MES-WMS/对接任务书/tools/_fix_uat01_carton.py
  27. 1 1
      doc/plan/旧DOP-MES-WMS/对接任务书/附录A-手册操作覆盖矩阵.md
  28. 3 1
      doc/plan/租户隔离彻底修正执行任务书.md
  29. 456 0
      doc/plan/租户隔离收口与造数前置任务书.md
  30. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  31. 25 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.335.sql
  32. 8 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.336.sql
  33. 192 13
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs
  34. 332 13
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs
  35. 68 2
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpOutboxAdminService.cs
  36. 65 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcTaskEntryDto.cs
  37. 22 74
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs
  38. 355 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcTaskEntryService.cs
  39. 538 0
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcWmsResultPushService.cs

+ 1 - 1
Web/package.json

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

+ 60 - 0
Web/src/views/aidop/s5/api/iqcTaskEntry.ts

@@ -0,0 +1,60 @@
+import service from '/@/utils/request';
+
+export interface IqcTaskEntryRow {
+	id?: number;
+	glid?: number;
+	applyBillNo?: string;
+	bizType?: string;
+	materialCode?: string;
+	materialName?: string;
+	spec?: string;
+	lotNo?: string;
+	receiver?: string;
+	applyQty?: number;
+	inspectStatus?: string;
+	ownerId?: string;
+	ownerName?: string;
+	priority?: number;
+	supplierCode?: string;
+	supplierName?: string;
+	shipmentNo?: string;
+	inspBillId?: number;
+	inspBillNo?: string;
+}
+
+export interface IqcTaskEntryPaged {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: IqcTaskEntryRow[];
+}
+
+export async function fetchIqcTaskEntryList(params: Record<string, unknown>) {
+	const res = await service.get('/api/S5IqcTaskEntry/list', { params });
+	return (res?.data ?? res) as IqcTaskEntryPaged;
+}
+
+export async function claimIqcTaskEntries(ids: number[]) {
+	const res = await service.post('/api/S5IqcTaskEntry/claim', { ids });
+	return res?.data ?? res;
+}
+
+export async function assignIqcTaskEntries(ids: number[], inspectorUserId: number) {
+	const res = await service.post('/api/S5IqcTaskEntry/assign', { ids, inspectorUserId });
+	return res?.data ?? res;
+}
+
+export async function adjustIqcTaskPriority(ids: number[], priority: number) {
+	const res = await service.post('/api/S5IqcTaskEntry/priority', { ids, priority });
+	return res?.data ?? res;
+}
+
+export async function completeIqcTaskEntries(ids: number[]) {
+	const res = await service.post('/api/S5IqcTaskEntry/complete', { ids });
+	return res?.data ?? res;
+}
+
+export async function generateIqcInspBill(entryId: number) {
+	const res = await service.post('/api/S5IqcTaskEntry/generate-inspbill', { entryId });
+	return (res?.data ?? res) as { inspBillId: number; inspBillNo: string };
+}

+ 61 - 64
Web/src/views/aidop/s5/iqc/iqcResultList.vue

@@ -1,5 +1,5 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
+	<AidopDemoShell :title="pageTitle" subtitle="来料检验单结果(读本库 qms_qcp_inspbill)">
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="单据编号">
 				<el-input v-model="query.billNo" clearable style="width: 200px" />
@@ -7,29 +7,17 @@
 			<el-form-item label="来源单据编号">
 				<el-input v-model="query.sourceBillNo" clearable style="width: 200px" />
 			</el-form-item>
-			<el-form-item label="处理方式">
-				<el-select v-model="query.handleMode" placeholder="请选择" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="检验人">
-				<el-select v-model="query.inspector" placeholder="请选择" clearable style="width: 200px" />
-			</el-form-item>
 			<el-form-item label="物料编码">
-				<el-input v-model="query.materialCode" clearable style="width: 200px" />
+				<el-input v-model="query.materialCode" clearable style="width: 160px" />
 			</el-form-item>
 			<el-form-item label="批次号">
-				<el-input v-model="query.batchNo" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="检验日期-始">
-				<el-date-picker v-model="query.inspectDateStart" type="date" value-format="YYYY-MM-DD" style="width: 180px" />
-			</el-form-item>
-			<el-form-item label="检验日期-终">
-				<el-date-picker v-model="query.inspectDateEnd" type="date" value-format="YYYY-MM-DD" style="width: 180px" />
-			</el-form-item>
-			<el-form-item label="偏差申请">
-				<el-select v-model="query.deviationApply" placeholder="请选择" clearable style="width: 200px" />
+				<el-input v-model="query.lotNo" clearable style="width: 140px" />
 			</el-form-item>
 			<el-form-item label="判定">
-				<el-select v-model="query.judgement" placeholder="请选择" clearable style="width: 200px" />
+				<el-select v-model="query.judgement" clearable style="width: 120px">
+					<el-option label="合格" :value="0" />
+					<el-option label="不合格" :value="1" />
+				</el-select>
 			</el-form-item>
 			<el-form-item>
 				<el-button type="primary" @click="doSearch">查询</el-button>
@@ -38,34 +26,22 @@
 		</el-form>
 
 		<el-table :data="rows" row-key="id" v-loading="loading" border stripe>
-			<el-table-column prop="billNo" label="单据编号" min-width="150" show-overflow-tooltip resizable />
-			<el-table-column prop="sourceBillNo" label="来源单据编号" min-width="150" show-overflow-tooltip resizable />
-			<el-table-column prop="billType" label="单据类型" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="bizType" label="业务类型" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="inspectDate" label="检验日期" min-width="160" resizable />
-			<el-table-column prop="qcOrg" label="质检组织" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="deviationApply" label="偏差申请" min-width="110" resizable />
-			<el-table-column prop="deviationDesc" label="偏差说明" min-width="160" show-overflow-tooltip resizable />
-			<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip resizable />
-			<el-table-column prop="materialCode" label="物料编码" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="materialName" label="物料名称" min-width="160" show-overflow-tooltip resizable />
-			<el-table-column prop="spec" label="规格型号" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="sampleQty" label="样本数量" min-width="110" resizable />
-			<el-table-column prop="sampleInfo" label="样品信息" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="batchNo" label="批次号" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="inspectStrictness" label="检验宽严度" min-width="120" resizable />
-			<el-table-column prop="judgement" label="判定" min-width="100" resizable />
-			<el-table-column prop="inspector" label="检验人" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="inspectQty" label="检验数量" min-width="110" resizable />
-			<el-table-column prop="qualifiedQty" label="合格数量" min-width="110" resizable />
-			<el-table-column prop="unqualifiedQty" label="不合格数量" min-width="120" resizable />
-			<el-table-column prop="retainedQty" label="留样数量" min-width="110" resizable />
-			<el-table-column prop="destroyedQty" label="破坏数量" min-width="110" resizable />
-			<el-table-column prop="handleMode" label="处理方式" min-width="120" resizable />
-			<el-table-column prop="defectCategory" label="不良问题分类" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="drawingNo" label="图号" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="supplierBatchNo" label="供应商批号" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="supplierName" label="供应商名称" min-width="160" show-overflow-tooltip resizable />
+			<el-table-column prop="billNo" label="单据编号" min-width="160" show-overflow-tooltip />
+			<el-table-column prop="sourceBillNo" label="来源单据编号" min-width="160" show-overflow-tooltip />
+			<el-table-column prop="materialCode" label="物料编码" min-width="130" show-overflow-tooltip />
+			<el-table-column prop="materialName" label="物料名称" min-width="150" show-overflow-tooltip />
+			<el-table-column prop="lotNo" label="批次号" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="inspectQty" label="检验数量" min-width="100" align="right" />
+			<el-table-column prop="qualifiedQty" label="合格数量" min-width="100" align="right" />
+			<el-table-column prop="unqualifiedQty" label="不合格数量" min-width="110" align="right" />
+			<el-table-column prop="judgement" label="判定" min-width="90">
+				<template #default="{ row }">
+					{{ row.judgement === 0 ? '合格' : row.judgement === 1 ? '不合格' : row.judgement ?? '' }}
+				</template>
+			</el-table-column>
+			<el-table-column prop="processMethod" label="处理方式" min-width="100" />
+			<el-table-column prop="billStatus" label="状态" min-width="100" />
+			<el-table-column prop="supplierName" label="供应商" min-width="140" show-overflow-tooltip />
 			<template #empty>
 				<el-empty description="暂无数据" />
 			</template>
@@ -78,15 +54,19 @@
 				:total="total"
 				:page-sizes="[10, 20, 50]"
 				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
 			/>
 		</div>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS5IqcResultList">
-import { computed, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchIqcInspBillList, type IqcInspBillRow } from '../api/iqcInspBill';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '来料检验结果列表');
@@ -94,40 +74,57 @@ const pageTitle = computed(() => (route.meta?.title as string) || '来料检验
 const query = reactive({
 	billNo: '',
 	sourceBillNo: '',
-	handleMode: '',
-	inspector: '',
 	materialCode: '',
-	batchNo: '',
-	inspectDateStart: '',
-	inspectDateEnd: '',
-	deviationApply: '',
-	judgement: '',
+	lotNo: '',
+	judgement: undefined as number | undefined,
 	page: 1,
 	pageSize: 10,
 });
 
-// 本批次为只读 UI 骨架,尚无真实检验结果接口:渲染空表,不发请求、不造数据。
 const loading = ref(false);
-const rows = ref<Record<string, unknown>[]>([]);
+const rows = ref<IqcInspBillRow[]>([]);
 const total = ref(0);
 
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchIqcInspBillList({
+			billNo: query.billNo,
+			sourceBillNo: query.sourceBillNo,
+			materialCode: query.materialCode,
+			lotNo: query.lotNo,
+			judgement: query.judgement,
+			page: query.page,
+			pageSize: query.pageSize,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error(e?.message || '加载检验结果失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
 function doSearch() {
 	query.page = 1;
+	loadList();
 }
 
 function resetQuery() {
 	query.billNo = '';
 	query.sourceBillNo = '';
-	query.handleMode = '';
-	query.inspector = '';
 	query.materialCode = '';
-	query.batchNo = '';
-	query.inspectDateStart = '';
-	query.inspectDateEnd = '';
-	query.deviationApply = '';
-	query.judgement = '';
+	query.lotNo = '';
+	query.judgement = undefined;
 	query.page = 1;
+	loadList();
 }
+
+onMounted(() => loadList());
+onActivated(() => loadList());
 </script>
 
 <style scoped lang="scss">

+ 322 - 122
Web/src/views/aidop/s5/iqc/iqcTaskList.vue

@@ -1,75 +1,140 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="基于来料检验单 qms_qcp_inspbill(只读列表)">
-		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
-			<el-form-item label="检验单号">
-				<el-input v-model="query.billNo" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="来源单号">
-				<el-input v-model="query.sourceBillNo" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="物料编码">
-				<el-input v-model="query.materialCode" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="物料名称">
-				<el-input v-model="query.materialName" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="供应商">
-				<el-input v-model="query.supplierName" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="批次">
-				<el-input v-model="query.lotNo" clearable style="width: 200px" />
-			</el-form-item>
-			<el-form-item label="检验状态">
-				<el-input v-model="query.billStatus" clearable style="width: 160px" />
-			</el-form-item>
-			<el-form-item label="检验员">
-				<el-input v-model="query.inspector" clearable style="width: 160px" />
-			</el-form-item>
-			<el-form-item label="检验日期">
-				<el-date-picker
-					v-model="inspectDateRange"
-					type="daterange"
-					value-format="YYYY-MM-DD"
-					range-separator="-"
-					start-placeholder="开始日期"
-					end-placeholder="结束日期"
-					unlink-panels
-					style="width: 260px"
-				/>
-			</el-form-item>
-			<el-form-item>
-				<el-button type="primary" @click="doSearch">查询</el-button>
-				<el-button @click="resetQuery">重置</el-button>
-			</el-form-item>
-		</el-form>
-
-		<el-table :data="rows" row-key="id" v-loading="loading" border stripe>
-			<el-table-column prop="billNo" label="检验单号" min-width="170" show-overflow-tooltip resizable />
-			<el-table-column prop="sourceBillNo" label="来源单号" min-width="170" show-overflow-tooltip resizable />
-			<el-table-column prop="materialCode" label="物料编码" min-width="130" show-overflow-tooltip resizable />
-			<el-table-column prop="materialName" label="物料名称" min-width="160" show-overflow-tooltip resizable />
-			<el-table-column prop="spec" label="规格型号" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip resizable />
-			<el-table-column prop="lotNo" label="批次" min-width="130" show-overflow-tooltip resizable />
-			<el-table-column prop="reportQty" label="报检数量" min-width="100" align="right" resizable />
-			<el-table-column prop="inspectQty" label="检验数量" min-width="100" align="right" resizable />
-			<el-table-column prop="qualifiedQty" label="合格数量" min-width="100" align="right" resizable />
-			<el-table-column prop="unqualifiedQty" label="不合格数量" min-width="110" align="right" resizable />
-			<el-table-column prop="judgement" label="判定" min-width="90" resizable />
-			<el-table-column prop="processMethod" label="处理方式" min-width="100" resizable />
-			<el-table-column prop="billStatus" label="状态" min-width="90" resizable />
-			<el-table-column prop="inspectStartDate" label="检验开始时间" min-width="150" resizable>
-				<template #default="{ row }">{{ fmtDate(row.inspectStartDate) }}</template>
-			</el-table-column>
-			<el-table-column label="操作" width="90" fixed="right">
-				<template #default="{ row }">
-					<el-button type="primary" link @click="openDetail(row)">查看</el-button>
-				</template>
-			</el-table-column>
-			<template #empty>
-				<el-empty description="暂无数据" />
-			</template>
-		</el-table>
+	<AidopDemoShell :title="pageTitle" subtitle="认领 → 生成检验单 → 录入结果(提交即回写 WMS)">
+		<el-tabs v-model="activeTab" @tab-change="onTabChange">
+			<el-tab-pane label="待检任务" name="entry">
+				<el-form :inline="true" :model="entryQuery" class="mb12" @submit.prevent>
+					<el-form-item label="报检单号">
+						<el-input v-model="entryQuery.applyBillNo" clearable style="width: 200px" />
+					</el-form-item>
+					<el-form-item label="物料编码">
+						<el-input v-model="entryQuery.materialCode" clearable style="width: 160px" />
+					</el-form-item>
+					<el-form-item label="批次">
+						<el-input v-model="entryQuery.lotNo" clearable style="width: 140px" />
+					</el-form-item>
+					<el-form-item label="检验状态">
+						<el-input v-model="entryQuery.inspectStatus" clearable style="width: 140px" placeholder="未检验/检验中/检验完成" />
+					</el-form-item>
+					<el-form-item label="收货单">
+						<el-input v-model="entryQuery.receiver" clearable style="width: 160px" />
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="doEntrySearch">查询</el-button>
+						<el-button @click="resetEntryQuery">重置</el-button>
+					</el-form-item>
+				</el-form>
+
+				<div class="mb12">
+					<el-button type="primary" :disabled="!selectedEntryIds.length" @click="onClaim">认领</el-button>
+					<el-button :disabled="!selectedEntryIds.length" @click="openAssign">检验员调配</el-button>
+					<el-button :disabled="!selectedEntryIds.length" @click="openPriority">优先级调整</el-button>
+					<el-button :disabled="!selectedEntryIds.length" @click="onComplete">检验完成</el-button>
+				</div>
+
+				<el-table
+					:data="entryRows"
+					row-key="id"
+					v-loading="entryLoading"
+					border
+					stripe
+					@selection-change="onEntrySelection"
+				>
+					<el-table-column type="selection" width="48" />
+					<el-table-column prop="applyBillNo" label="报检单号" min-width="170" show-overflow-tooltip />
+					<el-table-column prop="materialCode" label="物料编码" min-width="120" show-overflow-tooltip />
+					<el-table-column prop="materialName" label="物料名称" min-width="140" show-overflow-tooltip />
+					<el-table-column prop="lotNo" label="批次" min-width="120" show-overflow-tooltip />
+					<el-table-column prop="receiver" label="收货单" min-width="140" show-overflow-tooltip />
+					<el-table-column prop="applyQty" label="报检数量" min-width="90" align="right" />
+					<el-table-column prop="inspectStatus" label="状态" min-width="100" />
+					<el-table-column prop="ownerName" label="负责人" min-width="110" show-overflow-tooltip>
+						<template #default="{ row }">{{ row.ownerName || row.ownerId || '' }}</template>
+					</el-table-column>
+					<el-table-column prop="priority" label="优先级" min-width="80" />
+					<el-table-column prop="inspBillNo" label="检验单号" min-width="150" show-overflow-tooltip />
+					<el-table-column label="操作" width="180" fixed="right">
+						<template #default="{ row }">
+							<el-button
+								v-if="!row.inspBillId"
+								type="primary"
+								link
+								:loading="generatingId === row.id"
+								@click="onGenerate(row)"
+							>
+								来料检验
+							</el-button>
+							<el-button v-else type="primary" link @click="openDetailById(row.inspBillId)">查看检验单</el-button>
+						</template>
+					</el-table-column>
+					<template #empty>
+						<el-empty description="暂无报检任务(收货后由热回读同步)" />
+					</template>
+				</el-table>
+
+				<div class="pager">
+					<el-pagination
+						v-model:current-page="entryQuery.page"
+						v-model:page-size="entryQuery.pageSize"
+						:total="entryTotal"
+						:page-sizes="[10, 20, 50]"
+						layout="total, sizes, prev, pager, next"
+						@current-change="loadEntryList"
+						@size-change="loadEntryList"
+					/>
+				</div>
+			</el-tab-pane>
+
+			<el-tab-pane label="检验单" name="bill">
+				<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+					<el-form-item label="检验单号">
+						<el-input v-model="query.billNo" clearable style="width: 200px" />
+					</el-form-item>
+					<el-form-item label="来源单号">
+						<el-input v-model="query.sourceBillNo" clearable style="width: 200px" />
+					</el-form-item>
+					<el-form-item label="物料编码">
+						<el-input v-model="query.materialCode" clearable style="width: 160px" />
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="doSearch">查询</el-button>
+						<el-button @click="resetQuery">重置</el-button>
+					</el-form-item>
+				</el-form>
+
+				<el-table :data="rows" row-key="id" v-loading="loading" border stripe>
+					<el-table-column prop="billNo" label="检验单号" min-width="170" show-overflow-tooltip />
+					<el-table-column prop="sourceBillNo" label="来源单号" min-width="170" show-overflow-tooltip />
+					<el-table-column prop="materialCode" label="物料编码" min-width="130" show-overflow-tooltip />
+					<el-table-column prop="materialName" label="物料名称" min-width="160" show-overflow-tooltip />
+					<el-table-column prop="lotNo" label="批次" min-width="130" show-overflow-tooltip />
+					<el-table-column prop="reportQty" label="报检数量" min-width="100" align="right" />
+					<el-table-column prop="qualifiedQty" label="合格数量" min-width="100" align="right" />
+					<el-table-column prop="unqualifiedQty" label="不合格数量" min-width="110" align="right" />
+					<el-table-column prop="judgement" label="判定" min-width="90" />
+					<el-table-column prop="billStatus" label="状态" min-width="90" />
+					<el-table-column label="操作" width="90" fixed="right">
+						<template #default="{ row }">
+							<el-button type="primary" link @click="openDetail(row)">查看</el-button>
+						</template>
+					</el-table-column>
+					<template #empty>
+						<el-empty description="暂无检验单" />
+					</template>
+				</el-table>
+
+				<div class="pager">
+					<el-pagination
+						v-model:current-page="query.page"
+						v-model:page-size="query.pageSize"
+						:total="total"
+						:page-sizes="[10, 20, 50]"
+						layout="total, sizes, prev, pager, next"
+						@current-change="loadList"
+						@size-change="loadList"
+					/>
+				</div>
+			</el-tab-pane>
+		</el-tabs>
 
 		<IqcInspBillDetailDrawer
 			v-model="detailVisible"
@@ -84,24 +149,36 @@
 			@sqe-submit="onSqeSubmit"
 		/>
 
-		<div class="pager">
-			<el-pagination
-				v-model:current-page="query.page"
-				v-model:page-size="query.pageSize"
-				:total="total"
-				:page-sizes="[10, 20, 50]"
-				layout="total, sizes, prev, pager, next"
-				@current-change="loadList"
-				@size-change="loadList"
-			/>
-		</div>
+		<el-dialog v-model="assignVisible" title="检验员调配" width="420px">
+			<el-form label-width="100px">
+				<el-form-item label="用户 Id">
+					<el-input v-model="assignUserId" placeholder="填写检验员用户 Id" />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="assignVisible = false">取消</el-button>
+				<el-button type="primary" @click="onAssign">确定</el-button>
+			</template>
+		</el-dialog>
+
+		<el-dialog v-model="priorityVisible" title="优先级调整" width="420px">
+			<el-form label-width="100px">
+				<el-form-item label="优先级">
+					<el-input-number v-model="priorityValue" :min="1" :max="9999" />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="priorityVisible = false">取消</el-button>
+				<el-button type="primary" @click="onPriority">确定</el-button>
+			</template>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS5IqcTaskList">
 import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import { fetchIqcInspBillList, fetchIqcInspBillDetail, type IqcInspBillRow, type IqcInspBillDetail } from '../api/iqcInspBill';
 import {
@@ -114,15 +191,42 @@ import {
 	type IqcSubmitResultPayload,
 	type IqcSqeDispositionPayload,
 } from '../api/iqcInspBillFlow';
+import {
+	fetchIqcTaskEntryList,
+	claimIqcTaskEntries,
+	assignIqcTaskEntries,
+	adjustIqcTaskPriority,
+	completeIqcTaskEntries,
+	generateIqcInspBill,
+	type IqcTaskEntryRow,
+} from '../api/iqcTaskEntry';
 import IqcInspBillDetailDrawer from './components/IqcInspBillDetailDrawer.vue';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '来料检验任务列表');
 
-// S5 来料检验单只读列表(S5-IQC-INSPBILL-READONLY-LIST-1)。
-// 数据源 aidopdev.qms_qcp_inspbill 单表直读,后端 api/S5IqcInspBill。作后续检验流程主入口。
-// 只读:无认领/调配/检验完成/结果录入/主管审核/SQE处置/仓储通知/入库/详情/新增/编辑/删除/导出。
-// 表无 tenant_id、FORGID 全 NULL:不做租户/组织过滤。pd/clfs/FBILLSTATUS 字典未闭合 → raw 展示。
+const activeTab = ref('entry');
+
+const entryQuery = reactive({
+	applyBillNo: '',
+	materialCode: '',
+	lotNo: '',
+	inspectStatus: '',
+	receiver: '',
+	page: 1,
+	pageSize: 20,
+});
+const entryLoading = ref(false);
+const entryRows = ref<IqcTaskEntryRow[]>([]);
+const entryTotal = ref(0);
+const selectedEntryIds = ref<number[]>([]);
+const generatingId = ref<number | null>(null);
+
+const assignVisible = ref(false);
+const assignUserId = ref('');
+const priorityVisible = ref(false);
+const priorityValue = ref(1);
+
 const query = reactive({
 	billNo: '',
 	sourceBillNo: '',
@@ -138,13 +242,10 @@ const query = reactive({
 	pageSize: 20,
 });
 
-const inspectDateRange = ref<[string, string] | null>(null);
-
 const loading = ref(false);
 const rows = ref<IqcInspBillRow[]>([]);
 const total = ref(0);
 
-// 详情 drawer + 检验员/主管最小流程(S5-IQC-INSPBILL-INSPECTOR-SUPERVISOR-FLOW-1)。
 const detailVisible = ref(false);
 const detailLoading = ref(false);
 const currentDetail = ref<IqcInspBillDetail | null>(null);
@@ -153,9 +254,116 @@ const flowLoading = ref(false);
 const flowSubmitting = ref(false);
 const currentId = ref<number | null>(null);
 
-function fmtDate(v?: string | null) {
-	if (!v) return '';
-	return String(v).slice(0, 10);
+function onTabChange(name: string | number) {
+	if (name === 'entry') loadEntryList();
+	else loadList();
+}
+
+function onEntrySelection(rowsSel: IqcTaskEntryRow[]) {
+	selectedEntryIds.value = rowsSel.map((r) => Number(r.id)).filter((x) => x > 0);
+}
+
+async function loadEntryList() {
+	entryLoading.value = true;
+	try {
+		const data = await fetchIqcTaskEntryList({ ...entryQuery });
+		entryRows.value = data.list || [];
+		entryTotal.value = data.total || 0;
+	} catch (e: any) {
+		entryRows.value = [];
+		entryTotal.value = 0;
+		ElMessage.error(e?.message || '加载待检任务失败');
+	} finally {
+		entryLoading.value = false;
+	}
+}
+
+function doEntrySearch() {
+	entryQuery.page = 1;
+	loadEntryList();
+}
+
+function resetEntryQuery() {
+	entryQuery.applyBillNo = '';
+	entryQuery.materialCode = '';
+	entryQuery.lotNo = '';
+	entryQuery.inspectStatus = '';
+	entryQuery.receiver = '';
+	entryQuery.page = 1;
+	loadEntryList();
+}
+
+async function onClaim() {
+	try {
+		await claimIqcTaskEntries(selectedEntryIds.value);
+		ElMessage.success('认领成功');
+		loadEntryList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '认领失败');
+	}
+}
+
+function openAssign() {
+	assignUserId.value = '';
+	assignVisible.value = true;
+}
+
+async function onAssign() {
+	const uid = Number(assignUserId.value);
+	if (!uid) {
+		ElMessage.warning('请填写有效的用户 Id');
+		return;
+	}
+	try {
+		await assignIqcTaskEntries(selectedEntryIds.value, uid);
+		ElMessage.success('调配成功');
+		assignVisible.value = false;
+		loadEntryList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '调配失败');
+	}
+}
+
+function openPriority() {
+	priorityValue.value = 1;
+	priorityVisible.value = true;
+}
+
+async function onPriority() {
+	try {
+		await adjustIqcTaskPriority(selectedEntryIds.value, priorityValue.value);
+		ElMessage.success('优先级已更新');
+		priorityVisible.value = false;
+		loadEntryList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '调整失败');
+	}
+}
+
+async function onComplete() {
+	try {
+		await ElMessageBox.confirm('确认将这些任务标记为检验完成?', '提示');
+		await completeIqcTaskEntries(selectedEntryIds.value);
+		ElMessage.success('已标记检验完成');
+		loadEntryList();
+	} catch (e: any) {
+		if (e !== 'cancel') ElMessage.error(e?.message || '操作失败');
+	}
+}
+
+async function onGenerate(row: IqcTaskEntryRow) {
+	if (!row?.id) return;
+	generatingId.value = row.id;
+	try {
+		const res = await generateIqcInspBill(row.id);
+		ElMessage.success(`已生成检验单 ${res.inspBillNo}`);
+		await loadEntryList();
+		if (res.inspBillId) await openDetailById(res.inspBillId);
+	} catch (e: any) {
+		ElMessage.error(e?.message || '生成检验单失败');
+	} finally {
+		generatingId.value = null;
+	}
 }
 
 async function loadFlowState(id: number) {
@@ -176,39 +384,42 @@ async function refreshAfterAction() {
 		if (d) currentDetail.value = d;
 	}
 	loadList();
+	loadEntryList();
 }
 
-async function openDetail(row: IqcInspBillRow) {
-	if (!row?.id) {
-		ElMessage.error('检验单 id 缺失,无法查看详情');
-		return;
-	}
-	currentId.value = row.id;
+async function openDetailById(id: number) {
+	currentId.value = id;
 	detailLoading.value = true;
 	detailVisible.value = true;
 	currentDetail.value = null;
 	currentFlowState.value = null;
 	try {
-		const [data] = await Promise.all([fetchIqcInspBillDetail(row.id), loadFlowState(row.id)]);
+		const [data] = await Promise.all([fetchIqcInspBillDetail(id), loadFlowState(id)]);
 		if (!data) {
 			ElMessage.warning('未找到检验单详情');
-			currentDetail.value = null;
 			return;
 		}
 		currentDetail.value = data;
 	} catch (e: any) {
-		currentDetail.value = null;
 		ElMessage.error(e?.message || '加载检验单详情失败');
 	} finally {
 		detailLoading.value = false;
 	}
 }
 
+async function openDetail(row: IqcInspBillRow) {
+	if (!row?.id) {
+		ElMessage.error('检验单 id 缺失,无法查看详情');
+		return;
+	}
+	await openDetailById(row.id);
+}
+
 async function onSubmitResult(payload: IqcSubmitResultPayload) {
 	flowSubmitting.value = true;
 	try {
 		await submitIqcInspBillResult(payload);
-		ElMessage.success('检验结果已提交,转检验主管审核');
+		ElMessage.success('检验结果已提交(已触发回写 WMS),转检验主管审核');
 		await refreshAfterAction();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '提交检验结果失败');
@@ -265,13 +476,6 @@ async function loadList() {
 			billNo: query.billNo,
 			sourceBillNo: query.sourceBillNo,
 			materialCode: query.materialCode,
-			materialName: query.materialName,
-			supplierName: query.supplierName,
-			lotNo: query.lotNo,
-			billStatus: query.billStatus,
-			inspector: query.inspector,
-			inspectDateFrom: query.inspectDateFrom,
-			inspectDateTo: query.inspectDateTo,
 			page: query.page,
 			pageSize: query.pageSize,
 		});
@@ -287,8 +491,6 @@ async function loadList() {
 }
 
 function doSearch() {
-	query.inspectDateFrom = inspectDateRange.value?.[0] || '';
-	query.inspectDateTo = inspectDateRange.value?.[1] || '';
 	query.page = 1;
 	loadList();
 }
@@ -297,20 +499,18 @@ function resetQuery() {
 	query.billNo = '';
 	query.sourceBillNo = '';
 	query.materialCode = '';
-	query.materialName = '';
-	query.supplierName = '';
-	query.lotNo = '';
-	query.billStatus = '';
-	query.inspector = '';
-	inspectDateRange.value = null;
-	query.inspectDateFrom = '';
-	query.inspectDateTo = '';
 	query.page = 1;
 	loadList();
 }
 
-onMounted(() => loadList());
-onActivated(() => loadList());
+onMounted(() => {
+	loadEntryList();
+	loadList();
+});
+onActivated(() => {
+	loadEntryList();
+	loadList();
+});
 </script>
 
 <style scoped lang="scss">

+ 4 - 1
doc/README.md

@@ -65,8 +65,9 @@
 | [plan/Ai-DOP项目待办清单.md](./plan/Ai-DOP项目待办清单.md) | **项目级待办统一入口**(专题子文档见 §2 注册表) |
 | [plan/S1-S4业务功能测试结果汇总_20260712.md](./plan/S1-S4业务功能测试结果汇总_20260712.md) | S1–S4 API/E2E 测试结果(含样例单号、失败根因线索、原始 JSON 路径) |
 | [plan/数据中台方案复盘与未实现清单.md](./plan/数据中台方案复盘与未实现清单.md) | 数据中台总体方案、S1-S3 当前进度、未实现事项和后续优先级复盘 |
-| [plan/租户隔离彻底修正执行任务书.md](./plan/租户隔离彻底修正执行任务书.md) | **主执行任务书**:租户隔离问题的完整修正方案,分 4 批(公共解析器 / 止血 / 贴源根因 / 兜底值 / 种子脚本),含白名单、回归矩阵、数据清理与自检检索式 |
+| [plan/租户隔离彻底修正执行任务书.md](./plan/租户隔离彻底修正执行任务书.md) | **主执行任务书(进行中)**:租户隔离问题的完整修正方案,分 4 批(公共解析器 / 止血 / 贴源根因 / 兜底值 / 种子脚本),含白名单、回归矩阵、数据清理与自检检索式。批次 0/1/2/4 主体已随 `0da9ce11b`(server 1.0.327)落地;**剩余工作请执行下方收口任务书,勿重头再跑本书** |
 | [plan/租户隔离全量审计报告-20260810.md](./plan/租户隔离全量审计报告-20260810.md) | **审计依据**:租户号硬编码与缺过滤问题清单(写入侧写死 0 / 读取侧跨租户泄露 / 种子脚本硬编码三类),含实测污染数据 |
+| [plan/租户隔离收口与造数前置任务书.md](./plan/租户隔离收口与造数前置任务书.md) | **可派发任务书**(纯后端,含逐行 diff、16 个调用点清单与验收 SQL):主任务书批次 0/1/2/4 随 `0da9ce11b`(server 1.0.327)落地后剩余的三项收口,是多租户造数的放行条件——**WP-1** 齐套刷新「先删后建、失败即空」+ 存储过程 30 秒超时(实测已致 `dwd_material_readiness` 丢失 20872 行;根因是 Step 1 全表删除多余,Step 2/3 已幂等)、**WP-2** 写入侧租户零兜底(`MdpJsonSql` 三方法 `, 0)` + 7 处内联,`tenant_id=0` 的唯一剩余产生源)、**WP-3** 派生层零值残渣清理(含 `ado_s8_exception` 42 行 `tenant_id=1` 待定归属)。含「已落地勿重做」「已核实非缺陷勿改」两份白名单——**初版的「A 项:补齐跨租户 DELETE 租户条件」经核查不成立已撤销**(那三处 DWD 构建本就是一次重建全部租户,删除与重建范围自洽),由此派生出 P6「DWD/KPI 必须全局刷新一次」的使用约束 |
 | [plan/MDP贴源层租户映射缺失修复任务书.md](./plan/MDP贴源层租户映射缺失修复任务书.md) | ~~已被上方主任务书 §6 取代~~,保留作为贴源层单点问题的详细背景资料 |
 | [plan/旧DOP-MES-WMS/README.md](./plan/旧DOP-MES-WMS/README.md) | **旧 DOP·MES·WMS 梳理专题目录**(含新 Ai-DOP 对接现状对照) |
 | [plan/旧DOP-MES-WMS/对接任务书/README.md](./plan/旧DOP-MES-WMS/对接任务书/README.md) | **新 Ai-DOP 对接 165 SQL Server(MES/WMS)总方案与任务书**(执行须知 + WP1–WP8 + 手册操作覆盖矩阵;WP1/2/3/8 与 WP7 部分可派发执行) |
@@ -87,6 +88,8 @@
 | [plan/数据库迁移/S3/S3数据中台与看板动态化验收.md](./plan/数据库迁移/S3/S3数据中台与看板动态化验收.md) | S3 供应协同数据中台链路、看板动态化与验收 SQL |
 | [plan/数据中台模块扩展开发指南-S3范式.md](./plan/数据中台模块扩展开发指南-S3范式.md) | 数据中台扩展(仿 S3):四库逻辑、作业与 Cursor 协作 |
 | [plan/UAT缺陷整治任务书/README.md](./plan/UAT缺陷整治任务书/README.md) | **可派发任务书集**:2026-08-09 起真人 UAT 暴露缺陷的整治,拆为 4 个可独立交付的工作包——WP-A 死信告警任务修复(`SqlSugar` 私有字段)+ D-W01 归档、WP-B 库存新鲜度打通(齐套算料改读 165 实时库存)、WP-C 齐套两入口口径统一与结论不互相覆盖、WP-D 计划实绩合并(MES 影子计划行实绩回读与聚合)。含各包决策项、事实复核 SQL、验收判据与"明确不做"边界;**D-W01 / D-M01 根因均在 165 存储过程内,受"不改 165"硬约束,只做我方侧适配与归档** |
+| [plan/UAT多租户数据方案/README.md](./plan/UAT多租户数据方案/README.md) | **多租户 UAT/Demo 数据方案**:三套租户定位(A 真机联调 `…229` / B 纯演示 `…230` / C 新客户样板 `…565`)、2026-08-11 实测基线、D1–D13 决策定案、迁移脚本与手工重置脚本分工、P0–P6 执行阶段(P6 含「DWD/KPI 必须全局刷新一次、不得按租户分别调用」的硬约束)、跨模块影响与 165 约束下无法演示的路径。**首要结论:租户隔离收口未完成前不得开始造数**(`dwd_material_shortage` 146 行仍 100% 落在 `tenant_id=0`)。含两处代码级核查更正:补 `HolidayMaster` 不影响 S2 排程(该表仅被 S1 `CapacityDateCalculator` 消费),并由此登记「S2 排程按周模式排产、不识别节假日」的算法缺陷 |
+| [plan/UAT多租户数据方案/逐表清单.md](./plan/UAT多租户数据方案/逐表清单.md) | 上述方案的逐表落地清单:S0–S9 每张表在三套租户下的现状行数、目标行数、数据来源(裁剪/构造/修复/运行生成)与落地通道 |
 | [db/mdp/README.md](./db/mdp/README.md) | S4/S8 相关 mdp、dwd 等建表脚本说明与执行顺序 |
 | [指标模型动态配置方案.md](./指标模型动态配置方案.md) | 指标模型动态配置总体方案 |
 | [meeting/20260408/vue-tsc-既存类型错误说明.md](./meeting/20260408/vue-tsc-既存类型错误说明.md) | 前端类型问题说明 |

+ 459 - 0
doc/plan/S5-IQC合格上架过账(QcCheck)执行任务书.md

@@ -0,0 +1,459 @@
+# S5 · IQC 合格上架过账(QcCheck)执行任务书
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 补上「扫码收货 → 自动 IQC 报检 → IQC 检验合格 → **上架入库**」的最后一步:把货从 **待检库 1000 的质检库存** 过账到 **合格库 1001 的非限制库存** |
+| 编写日期 | 2026-08-11 |
+| 状态 | **已落地并验收(2026-08-11)**:后端 `1.0.333`;本样本 `iss-tr-ins` 过账成功;QcCheck 幂等预检已加 |
+| 前置 | P1–P3 已跑通并留证([`UAT留证/2026-08-11-IQC-P0取证/03-P1P2P3运行验收.md`](./UAT留证/2026-08-11-IQC-P0取证/03-P1P2P3运行验收.md));后端现值 `1.0.332` |
+| 父任务书 | [`S5来料检验IQC链路打通方案与执行任务书.md`](./S5来料检验IQC链路打通方案与执行任务书.md)(以下简称**父书**) |
+| ⚠️ 本书推翻父书两条已定决策 | **Q3(B-3a:执行器不新增调存储过程能力)** 与 **Q4(本次不做上架)**。详见 §3。执行者若按父书原文自检,会把本书判为违规——**遇冲突以本书为准**,并按 §6 卡片 P5-4 同步修订父书与 `00B` |
+| 样本 | PO `PO202608110003` / 收货单 `RC202608110001` / 批 `260811013` / 物料 `81HC0744` / 箱码 `10003232-20260811-00010010001`(500)/ 报检单 `RQLLJYSQ202608110001` / Domain `8010` / 租户 `797403760988229` |
+
+---
+
+## 1. 结论摘要
+
+**父书 §6.5「上架不在本方案范围,由 WMS APP 上架移库完成」这个前提是错的,实测已证伪。**
+
+2026-08-11 实测:P3 跑通后(箱码 `I→N`、报检分录 `检验完成`),真人在 WMS APP 上依次试了「上架移库」「检验确认」「检验后部分收货」三个菜单,**没有一个能完成 1000→1001 的入库**:
+
+- 「上架移库」两次都报 `以下物料_库位_批次非限制库存不足:出库数500 > 非限制库存数0`
+- 「检验确认」提交成功,但只写了标签流水,库存分文未动
+- 「检验后部分收货」要求填检验单号,本样本 `PurOrdRctDetail.QCNbr` 为空(父书 §6.3 已定论甲路径下不写该列),走不进去
+
+**根因不在操作方式,而在 165 自己的存储过程版本上**:
+
+| 过程 | 是否被旧 DOP 调用 | 甲路径(`@IsRctTemp=0`)合格时是否过账 |
+|---|---|---|
+| `pr_WMS_SaveIQCResult` | ✅ 是(`submitAfter`,父书 §2.2) | ❌ **不过账**。全文 14442 字符,`SaveInvUpShelf`/`QcCheck` 各 **0 处命中** |
+| `qms_WMS_SaveIQCResultcheck` | ❌ 否(仅 `submitBefore` 预检调 `qms_WMS_SaveIQCcheck` 间接引用) | ✅ 有 `@IsRctTemp=0 and @QCStatus='YES'` → `SaveInvUpShelf @TransCode='QcCheck'`,但**整段写库逻辑被 `/* */` 注释掉了,等于未启用** |
+
+也就是说:**这条链在旧 DOP 时代就是断的**。供应商写了新版逻辑但没启用,甲路径的质检库存永远出不来。P3 复刻得完全正确(与部署版 SP 行为一致),但复刻一个断链得到的仍然是断链。
+
+**能完成这步过账的入口只有一个**:`pr_WMS_BPM_SaveInvUpShelf` 的 `@TransCode='QcCheck'` 模式——它是全库唯一产生 `iss-tr-ins`(质检库存转出 + 非限制库存转入)的路径。APP「上架移库」调的是同一个过程,但 `@TransCode` 不是 `QcCheck`,走 `iss-tr` 扣非限制库存,所以对本样本必然失败,**不是操作错误,是 APP 里没有这个入口**。
+
+**因此本书的结论是**:这一步必须由 Ai-DOP 在判合格时调 `pr_WMS_BPM_SaveInvUpShelf @TransCode='QcCheck'` 补齐,等价于把供应商注释掉的那段逻辑接上。
+
+---
+
+## 2. 取证基线(2026-08-11 实测,全部可复现)
+
+> 取证一律用 `sys.sql_modules` 读定义原文,**不要按变量名或注释推断分支**(父书 §11.4 坑 8)。
+> MCP SQL 通道会拦截含写关键字的语句,读定义时用 `'upd'+'ate'` 拼接绕开(父书 §11.4 坑 10)。
+
+### 2.1 部署版 `pr_WMS_SaveIQCResult` 不过账(证据)
+
+```sql
+SELECT LEN(m.definition) AS len,
+       CHARINDEX('SaveInvUpShelf', m.definition) AS p1,
+       CHARINDEX('QcCheck', m.definition)        AS p2
+  FROM sys.sql_modules m JOIN sys.objects o ON o.object_id=m.object_id
+ WHERE o.name='pr_WMS_SaveIQCResult';
+-- 实测:len=14442, p1=0, p2=0
+```
+
+甲路径下它的净写入面(父书 §2.3 已定论,本书复核一致):
+
+| 动作 | 命中 | 说明 |
+|---|---|---|
+| `UPDATE MissedPrint`(Status/QtyAccN/QtyRejectN…) | ✅ | `I→N` |
+| `INSERT MissedPrintTransHist` | ✅ | `TransType='来料检验-通过'` |
+| `UPDATE PurOrdRctDetail`(含 `QCNbr`) | ❌ | 该 UPDATE 带 `and d.RctType='temp'`,本样本 `RctType='rc'` → 0 行 |
+| `pr_WMS_BPM_RctPurOrdAndPurOrdByBarcode`(收货过账) | ❌ | 门控 `@IsRctTemp>0` |
+| `pr_WMS_BPM_AddMobileTask @Name='InvUpShelf'` | ❌ | 同上 |
+| **任何库存过账** | ❌ | **过程内不存在 `SaveInvUpShelf` 调用** |
+
+### 2.2 未启用的新版里才有 QcCheck(证据原文)
+
+`qms_WMS_SaveIQCResultcheck`(21223 字符)内,被 `/* */` 包裹的段落中:
+
+```sql
+if @IsRctTemp=0 and @QtyAcc+@QtyReject>0 and (@PartRctFlag=2 or @QCStatus='YES')
+  begin
+    if @QtyAcc>0
+      begin
+        set @MissedRecIDs=stuff((select '|'+convert(varchar(20),RecID)
+                                   from #missedPrints where Status='N'
+                                  group by RecID for xml path('')),1,1,'')
+        exec pr_WMS_BPM_SaveInvUpShelf
+             @Domain=@Domain, @Details=@MissedRecIDs, @Shelf=@Shelf,
+             @TransCode='QcCheck', @UserNo=@UserNo, @IsProcCall=1,
+             @ReturnMsg=@Msg output
+        ...
+      end
+    if @QtyReject>0
+      begin
+        -- 不良品:@Shelf=@RejectInspectionLocation(来自 PurOrdControl)
+        exec pr_WMS_BPM_SaveInvUpShelf ... @TransCode='QcCheck' ...
+      end
+  end
+```
+
+新版还多读了一个 `@ItemLocation=ItemMaster.Location`(旧版没有这个变量),但因整段被注释而未被使用——**这就是本书 §3 Q10 库位取值规则的来源,不是我们发明的**。
+
+### 2.3 `pr_WMS_BPM_SaveInvUpShelf` 的 QcCheck 语义(实现依据,逐条实测)
+
+| # | 事实 | 定义原文依据 | 对本方案的意义 |
+|---|---|---|---|
+| 1 | `TransType` 选择 | `case when @TransCode='QcCheck' then 'iss-tr-ins' else 'iss-tr' end` | **QcCheck 才扣质检库存**;非 QcCheck 扣非限制库存 → APP「上架移库」必失败 |
+| 2 | `IsMove` 恒为 1 | `(case when @TransCode='QcCheck' then 1 when ... end) as IsMove` | 同库位也会过账;且下面第 3、4 条校验因此被跳过 |
+| 3 | **货架存在性校验被跳过** | `if exists(... where IsMove=0) and not exists(LocationShelfMaster ...)` | QcCheck 无 `IsMove=0` 行 → `@Shelf` 可只给库位号、不带货架 |
+| 4 | **库位确认权限校验被跳过** | `if IsNull(@IsShelfAcc,0)=1 and @TransCode<>'QcCheck'` | 本域 `IsShelfAcc=true`,若非 QcCheck 会校验 `@UserNo` 的库位确认权限 |
+| 5 | **标签货架保持原值** | `Shelf=(case when @TransCode='QcCheck' then m.Shelf ... else @InvShelf end)` | 传 `@Shelf='1001'` 不会清空 `MissedPrint.Shelf`,它保留 `01-01-01`(且 `1001/01-01-01` 在 `LocationShelfMaster` 中存在) |
+| 6 | 标签库位改写 | `Location=... else @LocationTo end` | `MissedPrint.Location`:`1000` → `1001` |
+| 7 | 会生成转仓单号并写 `OrdNbr` | `OrdNbr=(case when IsMove=1 then @NbrResult ...)`;`@NbrType='TA'` → `exec pr_SFM_GetOrdNbr` | **165 会新增 `NbrMaster`/`NbrDetail` 转仓单 + `NbrDayInfo` 当期行**。这是 SP 自身行为(APP 上架同样如此),不是本方案额外写表 |
+| 8 | 留样/破坏数会从标签扣减 | `Qty=(case when @TransCode='QcCheck' then m.Qty-t.InsSampleQty ...)`,`InsSampleQty=m.CompQty+m.QtyIssued` | 本样本 `CompQty=QtyIssued=0`,无影响;**有留样的场景本书不覆盖**(§9 U-A) |
+| 9 | 库位路线权限校验 | 门控 `if @CompanyCode='NBLH'`,本域 `CompanyCode='TJRQ'` | **不执行**,无需 `EmpLocRouteAcc` 数据 |
+| 10 | **`@IsProcCall=1` 时不提交也不回滚** | `if @IsProcCall=0 begin if(@tran_error>0) rollback ... else commit ... end` | **最关键的坑**,见 §4.2 |
+| 11 | 成功消息 | `set @ReturnMsg='保存成功!'`;失败为 `'保存失败,'+...` | 判据 = `@ReturnMsg` 含 `成功` |
+| 12 | `@Details` 格式 | 无逗号时 `insert @tbDetails(MissedPrintRecID) exec(@sql)`,`|` 分隔多条 | 单个 RecID 直接传,如 `'126642'` |
+| 13 | `@Shelf` 拆分 | `ShelfSplitCode` 未配置 → 默认 `:`;无 `:` 时 `@LocationTo=@Shelf`、`@InvShelf=''` | 传 `'1001'` 即目标库位、货架空 |
+| 14 | `@IsProcCall=1` 不动手机任务 | `MobileTaskHistory` 写入与 `delete MobileTask` 都在 `if @IsProcCall=0` 内 | 我们不是从手机任务发起,正好不该动它 |
+
+质检库存充足性由下游 `pr_SFM_InventoryTransactionProcessing2` 校验(错误文案「质检库存不足」/「非限制库存不足」)。
+
+### 2.4 主数据(库位规则的唯一来源,勿另造临时值)
+
+| 对象 | 实测值 | 含义 |
+|---|---|---|
+| `PurOrdControl.InspectionLocation` | `1000` | 收货入待检库 → 本样本现状 |
+| `PurOrdControl.RejectInspectionLocation` | `1004` | 来料检验**不良品**库位 |
+| `ItemMaster.Location`(`81HC0744`) | `1001` | 物料默认库位 → **合格品目标库位** |
+| `LocationMaster.Typed` | `1000=材料待检库`、`1001=材料合格库` | 与上面两条自洽 |
+| `LocationShelfMaster` | `1001/01-01-01` **存在**;`1000/01-01-01` **不存在** | 父书 U5 记录的既有现象;QcCheck 不校验货架,不阻断 |
+| `InvControl` | `IsInvFixed=false`、`IsShelfAcc=true`、`IsLotControl=true` | 未盘点冻结;`IsShelfAcc` 在 QcCheck 下不触发权限校验(§2.3 #4) |
+| `NbrControl.NbrType='ta'` | `转仓单号`、`NbrPre1+2='TA'`、`DateType='YYYYMM'`、`NextValue=1`、`MaxValue=9999`、`Domain=8010` | 转仓单号可正常生成,形如 `TA2026080001`。`pr_SFM_GetOrdNbr` **不检查** `IsStart`/`IsActive`,当前 `IsStart=false` 不阻断 |
+| `GeneralizedCodeMaster` | `SystemConfig/CompanyCode='TJRQ'`;`OAType/iss-tr` **未配置**;`SystemConfig/IsTrToRctWoRed` **未配置** | 前者使 §2.3 #9 不执行;后两者经 APP 实测(报错停在库存校验,说明已跑过这些段)确认不阻断 |
+
+### 2.5 本库 RecID ≠ 165 RecID(必读,否则会把货移错)
+
+| 库 | `MissedPrint.RecID`(同一箱码) |
+|---|---|
+| 165 `dopdemorq` | **126642** |
+| 本库 `aidopdev` | **127994** |
+
+两侧都是各自 IDENTITY 自增,**不可互用**。`@Details` 必须在 165 上按 `Domain + BarCode` 现查 RecID(用执行器已有的 `resolve` 机制,`MissedPrint` 已在回写白名单内)。
+
+### 2.6 当前样本快照(P5 的验收基线,2026-08-11 17:5x)
+
+```sql
+-- 165
+SELECT Location, ItemNum, LotSerial, QtyOnHand, IsNull(Assay,0) AS Assay,
+       IsNull(AvailStatusQty,0) AS AvailStatusQty
+  FROM LocationDetail WITH(NOLOCK)
+ WHERE Domain='8010' AND ItemNum='81HC0744' AND LotSerial='260811013';
+-- 实测:1000 / 81HC0744 / 260811013 / QtyOnHand=500 / Assay=500 / AvailStatusQty=0
+```
+
+| 对象 | 关键列 | 值 |
+|---|---|---|
+| 165 `MissedPrint` | RecID / Status / Location / Shelf / QtyAccN / CheckNo | `126642` / `N` / `1000` / `01-01-01` / `500` / `RQLLJYSQ202608110001` |
+| 165 `LocationDetail` | Location / QtyOnHand / Assay / AvailStatusQty | `1000` / `500` / **`500`** / **`0`** |
+| 165 `qms_qcp_insappnentry` | FINSPECTSTATUS | `检验完成` |
+| 165 `NbrMaster` / `NbrDetail`(TA) | 行数 | 0 |
+| 本库 `ItemMaster.Location` | — | `1001`(镜像值可信,与 165 一致) |
+
+---
+
+## 3. 决策(本书新增,含推翻父书两条)
+
+| 编号 | 决策 | 结论 | 理由 |
+|---|---|---|---|
+| **Q8** | 这一步过账由谁做 | **Ai-DOP 判合格时补做**(用户 2026-08-11 拍板) | APP 无此入口(§1 实测);旧 DOP 部署版 SP 也不做;不做则待检库的货永远出不来 |
+| **Q9** | 执行器是否新增调存储过程能力 | **开例外,新增 `op=PROC`**,过程名白名单本轮只放 `pr_WMS_BPM_SaveInvUpShelf` | **推翻父书 Q3 / §11.2 / §6.4 与 `00B` §7.2**。理由见下方专段 |
+| **Q10** | 目标库位/货架怎么取 | 合格品 → `ItemMaster.Location`;不良品 → `PurOrdControl.RejectInspectionLocation`;**货架不传**(QcCheck 会保留标签原货架) | 全部取自 165 既有主数据,与未启用新版 SP 的意图一致(§2.2 的 `@ItemLocation` / `@RejectInspectionLocation`)。用户明确要求「按实际合理的方式,不要为了测试搞临时的」 |
+| **Q11** | 是否同期覆盖不良品路径 | **是**(成本极小,同一条消息换库位参数) | 不做的话不合格判定同样会卡在库存里,等于留第二个断点 |
+| **Q12** | 是否覆盖留样/破坏数场景 | **不覆盖**,见 §9 U-A | 本样本 `CompQty=QtyIssued=0`;留样需 `qms_qcp_inspbill.cbzx`(成本中心)等配套,属另一份任务书 |
+
+### Q9 为什么必须推翻父书 Q3(执行者必读,否则会自检失败并拒绝执行)
+
+父书 §6.4 的原文论证是:
+
+> `pr_WMS_SaveIQCResult` 在本例路径(`@IsRctTemp=0`)下净写入面只有两处,字段级足够覆盖。因此无需为本包扩展执行器的存储过程调用能力。
+
+**这个论证本身没错,错的是它的目标**——它只保证「与部署版 SP 行为一致」,而部署版 SP 恰恰不完成上架入库。守住 Q3 的代价是链路永久断在最后一步。
+
+两条替代路都不可行:
+
+| 替代方案 | 为什么否 |
+|---|---|
+| 用字段级 UPDATE 复刻 `iss-tr-ins` 过账 | 需自行改写 `LocationDetail.Assay/AvailStatusQty/QtyOnHand`、`InvTransHist`、`NbrMaster/NbrDetail`,且 `pr_SFM_InventoryTransactionProcessing2`(16969 字符)里还有一整套库存状态校验与 ERP 同步标记。**这才是真正的「双写库存」**,风险远高于调 SP,且父书 §6.3 明令禁止写这三张表 |
+| 改走乙路径(暂收)重造样本 | 需把收货环节改成暂收,`PurOrdDetail.ReceiptQty>0` → `@IsRctTemp=1`;而 `PurOrdDetail.ReceiptQty` 是父书 §6.3 标注的**最高危禁写项**,改它会把 165 的收货路径整体切换。工作量与风险都更大 |
+
+**结论**:调 165 自己的过账过程,是「不双写库存」这条红线下唯一自洽的实现。例外范围严格限定为:
+
+- 只允许 `op=PROC`,且过程名必须在代码常量白名单内,本轮**只有 `pr_WMS_BPM_SaveInvUpShelf` 一个**
+- 白名单新增过程需走与本书同级的评审,不得由执行者自行添加
+- `00B` §7.2「不在新系统里调用或新建存储过程」的**「新建」部分不开例外**:仍然禁止在 165 上新建/修改任何存储过程
+
+---
+
+## 4. 设计定本
+
+### 4.1 payload 契约(`op=PROC`)
+
+```json
+{
+  "op": "PROC",
+  "proc": "pr_WMS_BPM_SaveInvUpShelf",
+  "args": {
+    "Domain": "8010",
+    "Shelf": "1001",
+    "TransCode": "QcCheck",
+    "UserNo": "AIDOP",
+    "IsProcCall": 1
+  },
+  "resolve": {
+    "Details": {
+      "table": "MissedPrint",
+      "column": "RecID",
+      "match": { "Domain": "8010", "BarCode": "10003232-20260811-00010010001" }
+    }
+  },
+  "output": "ReturnMsg",
+  "success": "成功",
+  "idempotent": ["已是此货架", "已是此位置"]
+}
+```
+
+约束:
+
+- `proc` 必须命中 `AllowedProcs` 白名单,否则抛异常(与现有 `ValidateTable` 同风格)
+- `args` 的键必须匹配 `^[A-Za-z0-9_]+$`(复用现有 `ColNameRegex`);值走现有 `CoerceDbValue`
+- `resolve` 的目标写入 `args`(现有实现只写 `insert`,需按 `op` 分派);`resolve` 未命中时返回失败让 dispatcher 退避重试(现有语义,不改)
+- `output` 指定 OUTPUT 参数名;`success` 为成功子串;`idempotent` 命中任一子串时判为幂等 `Skip`
+
+### 4.2 执行语义(**不得写成裸 EXEC**)
+
+`pr_WMS_BPM_SaveInvUpShelf` 在 `@IsProcCall=1` 下 `begin transaction tranUpShelf` 之后**既不 commit 也不 rollback**(§2.3 #10),必须由调用方收口,且 SP 内那层事务会让 `@@TRANCOUNT` 多出 1,单次 `COMMIT` 提交不完。执行器必须下发如下批语句:
+
+```sql
+SET XACT_ABORT ON;
+DECLARE @ret varchar(max) = '';
+BEGIN TRAN;
+EXEC [pr_WMS_BPM_SaveInvUpShelf]
+     @Domain=@a0, @Details=@a1, @Shelf=@a2, @TransCode=@a3,
+     @UserNo=@a4, @IsProcCall=@a5, @ReturnMsg=@ret OUTPUT;
+IF CHARINDEX(N'成功', ISNULL(@ret,'')) = 0
+    WHILE @@TRANCOUNT > 0 ROLLBACK;
+ELSE
+    WHILE @@TRANCOUNT > 0 COMMIT;
+SELECT @ret AS ReturnMsg;
+```
+
+要点:
+
+- 参数名顺序由 `args` 决定,全部参数化,**禁止字符串拼接值**
+- 回读 `ReturnMsg` 用结果集(`SELECT @ret`),比依赖 SqlSugar 的 OUTPUT 参数回填更稳;若用 OUTPUT 参数,须实测 `SugarParameter` 的 `Direction`/`Size` 能正确回填
+- 判定:含 `成功` → `MdpPushResult.Ok`;命中 `idempotent` 子串 → `Skip`;其余 → `Fail(@ret)` 交给现有退避重试
+- 失败时必须确保事务已回滚,不得把未提交事务留给连接池
+
+### 4.3 幂等与重试
+
+**PROC 天然不幂等**:同一箱码重复过账会重复移库。三层防护:
+
+1. `idem_key = iqc|upshelf|{qcNbr}|{barcode}`,`MdpOutboxEnqueueService` 保证不重复入队
+2. **执行器预检(必做)**:`TransCode=QcCheck` 且 `@Details` 为单 RecID 时,查 `MissedPrint.Location`;若已等于目标库位(`@Shelf` 去货架段)→ 直接 `Skip`,**不调 SP**
+3. SP 侧「已是此货架」作兜底(`idempotent` 子串)——注意:`@Shelf` 只传库位号时 `@InvShelf=''`,SP 该校验**经常不命中**,故不能只靠第 3 层
+4. 每箱码一条消息(`@Details` 单个 RecID),失败只重试该箱,不会整批重放
+
+> **实测教训(2026-08-11)**:缺少第 2 层时,同库位二次 QcCheck 仍返回「保存成功!」,对 `1001` 再跑一对 `iss-tr-ins`,把 `Assay` 打成 −500、`AvailStatusQty` 打成 1000。已纠偏并补预检。
+
+### 4.4 与父书 §6.1 状态机的衔接
+
+父书状态机第 6 步「上架移库 / WMS APP」**改为**:
+
+| 步 | 动作 | 执行方 | `MissedPrint` | `LocationDetail` |
+|---|---|---|---|---|
+| 5 | 提交结果(判合格) | Ai-DOP(P3) | `I→N`,Location 仍 `1000` | `1000`:`Assay=500`、`Avail=0` |
+| **6** | **合格上架过账(QcCheck)** | **Ai-DOP(本书 P5)** | Location `1000→1001`,Shelf 保持 `01-01-01`,`OrdNbr=TA…` | `1000` `Assay` 减 500;`1001` `AvailStatusQty` 增 500 |
+| 6b | 货架微调(可选,非必须) | WMS APP「上架移库」 | 同库位内改货架 | 不变(`iss-tr` 此时有非限制库存可扣,APP 已可用) |
+| 7 | 回读 | Ai-DOP | 同步 | 合格入库数(`pcrksl`)出数 |
+
+**第 6 步之后 APP「上架移库」才真正可用**(非限制库存已存在),所以本方案不是取代 APP,而是补上 APP 用不了的那一段。
+
+---
+
+## 5. 改动清单(文件级)
+
+### 5.1 `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs`
+
+| # | 改动 | 说明 |
+|---|---|---|
+| 1 | 新增 `AllowedProcs` 常量集合 | 本轮只含 `pr_WMS_BPM_SaveInvUpShelf`;校验风格照 `ValidateTable` |
+| 2 | `DbPushPayload` 增字段 | `Proc`、`Args`、`Output`、`Success`、`Idempotent` |
+| 3 | `ParsePayload` 增读 | `proc` / `args` / `output` / `success` / `idempotent` |
+| 4 | `ApplyResolvesAsync` 按 op 分派 | `op=PROC` 时写入 `Args`,其余仍写 `Insert` |
+| 5 | 新增 `ExecProcAsync` | 按 §4.2 下发批语句;成功/幂等/失败三态返回 |
+| 6 | `op switch` 增 `"PROC"` | 现有 `INSERT/UPDATE/UPSERT` 分支**一行不改** |
+| 7 | `ValidateTable(payload.Table)` 的调用位置需调整 | `op=PROC` 无 `table`,不得因空表名抛异常 |
+
+### 5.2 `server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcWmsResultPushService.cs`
+
+| # | 改动 | 说明 |
+|---|---|---|
+| 1 | 新增 `ActionUpShelf = "IQC_WMS_UPSHELF"` | |
+| 2 | 查目标库位 | 合格品:本库 `ItemMaster.Location`(按 `ItemNum`,`Domain` 用 `IFNULL(Domain,'') IN ('',@Domain)`,父书坑 1);不良品:165 `PurOrdControl.RejectInspectionLocation`(可经 `resolve` 或入队前查一次) |
+| 3 | 逐箱码入队 PROC 消息 | 仅对本次计算出 `newStatus='N'`(合格/让步/挑选命中)与 `'R'`(退货,走不良品库位)的箱码;`newStatus=='KEEP'` 的**不入队** |
+| 4 | 顺序 | 排在 `ActionMp`(箱码状态)之后,最后一条置 `pulse: true` |
+| 5 | 库位取不到时 | **不入队 + `LogWarning`**,禁止兜底猜库位 |
+| 6 | 顺带修 P3 遗留 | `qms_qcp_inspbill` UPSERT 的 `insert` 补 `["id"] = bill.Id`,修「不能将值 NULL 插入列 'id'」 |
+
+> ⚠️ 现有 `MapResult` 中 `KEEP` 表示挑选且无箱码清单时保持原状态(父书 §6.3),这类箱码**没有解禁也不该上架**,务必排除。
+
+### 5.3 文档(本卡片交付物,缺则后续执行者会按旧结论否掉本方案)
+
+| 文件 | 改动 |
+|---|---|
+| `doc/plan/S5来料检验IQC链路打通方案与执行任务书.md` | Q3 标注「已被本书 Q9 推翻」并保留原文;Q4 同理;§6.4、§6.5、§11.2、§11.8 加指向本书的例外说明;§6.1 状态机第 6 步按 §4.4 更新 |
+| `doc/plan/旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md` | §7.2 加已批例外(限 `op=PROC` + 白名单一个过程);§6 自检第 2 项的 `rg` 检查改为「除白名单外无命中」 |
+| `doc/plan/UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md` | 用 §1 根因替换「待 APP 完成」的结论,记录三个菜单均不可行及其原因 |
+
+### 5.4 版本号与 DDL
+
+- 本轮**只动后端** → `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 三处 `1.0.332` → `1.0.333`
+- **不需要 UpdateScript**:`mdp_outbox` 已存在,白名单是代码常量
+- 纯文档部分单独提交时不升版本号
+
+---
+
+## 6. 执行卡片
+
+### P5-0 · 开工前预检(只读,5 分钟)
+
+逐条跑通再开工,任一不符先停下报告:
+
+```sql
+-- ① 库存仍在待检库且质检库存充足
+SELECT Location, QtyOnHand, IsNull(Assay,0) AS Assay, IsNull(AvailStatusQty,0) AS Avail
+  FROM LocationDetail WITH(NOLOCK)
+ WHERE Domain='8010' AND ItemNum='81HC0744' AND LotSerial='260811013';
+-- 期望:1000 / 500 / 500 / 0
+
+-- ② 标签可上架(Status 非 C/Q,Type 非 Card,Op=0)
+SELECT RecID, Status, IsNull(Type,'') AS Type, Location, Shelf, Op, IsNull(CompQty,0)+IsNull(QtyIssued,0) AS InsSample
+  FROM MissedPrint WITH(NOLOCK)
+ WHERE Domain='8010' AND BarCode='10003232-20260811-00010010001';
+-- 期望:126642 / N / '' / 1000 / 01-01-01 / 0 / 0
+
+-- ③ 未盘点冻结
+SELECT IsNull(IsInvFixed,0) FROM InvControl WITH(NOLOCK) WHERE Domain='8010';   -- 期望 0
+
+-- ④ 目标库位与不良品库位
+SELECT Location FROM ItemMaster WITH(NOLOCK) WHERE Domain='8010' AND ItemNum='81HC0744';  -- 期望 1001
+SELECT RejectInspectionLocation FROM PurOrdControl WITH(NOLOCK) WHERE Domain='8010';       -- 期望 1004
+
+-- ⑤ 转仓单号段可用
+SELECT NbrType, NbrPre1+NbrPre2 AS Pre, DateType, NextValue, MaxValue
+  FROM NbrControl WITH(NOLOCK) WHERE Domain='8010' AND NbrType='ta';            -- 期望有行
+
+-- ⑥ 只有一个后端实例在跑(父书坑 7:旧实例会抢 mdp_outbox)
+```
+
+### P5-1 · 执行器新增 `op=PROC`
+
+- 按 §5.1 改 `MdpDbPushExecutor.cs`
+- `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 通过
+- **单点验证(不动业务代码)**:手工插一条 PROC 消息到 `mdp_outbox`,观察 165 变化。这一步同时也是本样本 P4 的补跑(见 P5-3)
+
+### P5-2 · IQC 合格分支接线
+
+- 按 §5.2 改 `IqcWmsResultPushService.cs`
+- 用**另一箱码**(`10003232-20260811-00010010002`,500)走一次完整链路验证接线:收货 → 报检回读 → 认领建单 → 提交合格 → 自动完成上架过账
+- 本样本(`…0010001`)P3 已终态,重走 `submit-result` 会被幂等键拦,**不要靠它验证接线**
+
+### P5-3 · 本样本补跑上架过账(完成 P4 留证)
+
+`…0010001` 已过 P3,只缺第 6 步。手工入队一条消息让它走完(走的是真实执行器,不是手工改库):
+
+```sql
+-- aidopdev
+INSERT INTO mdp_outbox
+  (tenant_id, target_source_code, action_code, idem_key, payload_json,
+   status, retry_count, create_time, update_time)
+VALUES
+  (797403760988229, 'DOPDEMORQ_SQLSERVER', 'IQC_WMS_UPSHELF',
+   'iqc|upshelf|RQLLJYSQ202608110001|10003232-20260811-00010010001',
+   '{"op":"PROC","proc":"pr_WMS_BPM_SaveInvUpShelf","args":{"Domain":"8010","Shelf":"1001","TransCode":"QcCheck","UserNo":"AIDOP","IsProcCall":1},"resolve":{"Details":{"table":"MissedPrint","column":"RecID","match":{"Domain":"8010","BarCode":"10003232-20260811-00010010001"}}},"output":"ReturnMsg","success":"成功","idempotent":["已是此货架","已是此位置"]}',
+   0, 0, NOW(), NOW());
+```
+
+然后等 `MdpOutboxPushJob`(60 秒)或触发唤醒,按 §7 核对。
+
+> `UserNo` 现有实现取 `qms_qcp_inspbill.jyr`(形如 `u_xxx`)或回退 `AIDOP`,它会落到 `InvTransHist.UserID` 与转仓单 `CreateUser`。旧系统此处传的是 `rf_user.account`,口径不同 → 记入 §9 U-B,本轮不改以保持与 P3 一致。
+
+### P5-4 · 文档修订与提交
+
+- 按 §5.3 改三份文档 + `00B`
+- 后端版本 `1.0.333`
+- 自检见 §8
+
+---
+
+## 7. 验收(逐条实测,留证到 `doc/plan/UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md`)
+
+| # | 检查 | 期望 |
+|---|---|---|
+| 1 | `mdp_outbox` 该条 | `status=1`,`response_json`/`error_msg` 无异常,`retry_count=0` |
+| 2 | `LocationDetail` @ `1000` | `Assay` 由 500 → **0**(该行 `QtyOnHand=0` 或行消失,按 SP 语义) |
+| 3 | `LocationDetail` @ `1001` | `AvailStatusQty` **=500**,`Assay=0` |
+| 4 | `InvTransHist` | 新增 **2 行** `TransType='iss-tr-ins'`:`Location=1000` 负 500、`Location=1001` 正 500,`OrderNbr=TA…` |
+| 5 | `MissedPrint`(126642) | `Location='1001'`、`Shelf='01-01-01'`(**保持不变**)、`Status='N'`、`OrdNbr='TA…'` |
+| 6 | `NbrMaster`/`NbrDetail` | 新增转仓单 1 张,`NbrDayInfo` 出现 `ta` 当期行 |
+| 7 | `MobileTask` | **仍为 0**(`@IsProcCall=1` 不动手机任务) |
+| 8 | 幂等 | 重放同一 payload(改 `idem_key` 强制再推一次)→ 报「已是此货架」并被判 `Skip`,库存**不再变动** |
+| 9 | Ai-DOP 回读 | 合格入库数(`pcrksl` 口径)出数,P95 ≤ 10 秒(WP8 口径) |
+| 10 | APP 复验 | WMS APP「上架移库」此时扫该箱码**可以成功**(1001 内换货架),证明非限制库存已到位 |
+| 11 | 不良品分支 | 用备用箱码判不合格退货,核对目标库位为 `1004`(若本轮不具备样本,记为待验并在 §9 登记) |
+
+失败时的排查顺序:`mdp_outbox.error_msg` → 是否含「质检库存不足」(库存已被别的动作动过)/「标签已是此货架」(已成功过)/「创建单号失败」(TA 段异常)→ 再回 §2.3 对照门控。
+
+---
+
+## 8. 自检清单(父书 §11.8 的差异版,以本表为准)
+
+- [ ] 后端可编译
+- [ ] **存储过程调用检查改为白名单口径**:`rg -n "EXEC\s+pr_|CALL\s+pr_" server/` 的命中**只允许**出现在 `MdpDbPushExecutor` 的 `op=PROC` 实现与 `AllowedProcs` 常量中,且过程名只有 `pr_WMS_BPM_SaveInvUpShelf`
+- [ ] `AllowedProcs` 只有一项,且有注释指向本书 §3 Q9
+- [ ] 165 上未新建/未修改任何存储过程、表结构、触发器、作业
+- [ ] 未直接 UPDATE/INSERT `LocationDetail`、`InvTransHist`、`NbrMaster`、`NbrDetail`(这些由 SP 自己写)
+- [ ] 未写 `PurOrdRctDetail` 任何列、未写 `PurOrdDetail.ReceiptQty`(父书 §6.3 禁止写清单继续有效)
+- [ ] PROC 批语句全参数化,无字符串拼接值
+- [ ] 事务收口正确:失败路径下 `@@TRANCOUNT` 归零(可用一次故意失败的调用验证,如传不存在的 RecID)
+- [ ] `@Details` 来自 165 现查 RecID,**代码中不存在使用本库 RecID 的路径**(§2.5)
+- [ ] 目标库位来自主数据,代码中无硬编码 `1001`/`1004` 字面量
+- [ ] `newStatus='KEEP'` 的箱码未被入队上架
+- [ ] 幂等:重放不产生第二次移库(§7 #8 实测)
+- [ ] 后端版本 `1.0.333`;纯文档提交不升号
+- [ ] §5.3 三份文档 + `00B` 修订已完成
+- [ ] 交付物中无任何「在旧 DOP 上操作」的步骤(`00B` §4.1 第 3 条)
+
+---
+
+## 9. 未解问题与风险
+
+| # | 项 | 处置 |
+|---|---|---|
+| **U-A** | 留样/破坏数场景未覆盖 | QcCheck 会按 `MissedPrint.CompQty+QtyIssued` 扣减标签数量并清零两列(§2.3 #8),且 `qms_WMS_SaveIQCResultcheck` 里有「有留样数则成本中心不能为空」的校验。本样本为 0,未验证。**遇到有留样的检验单前,须另立任务书** |
+| **U-B** | `@UserNo` 口径 | 旧系统传 `rf_user.account`,本方案沿用现有 `userNo`(`jyr` 或 `AIDOP`),会落到 `InvTransHist.UserID`。为与 P3 保持一致,本轮不改;若 165 侧有按操作人对账的诉求需统一 |
+| **U-C** | 真实库存过账不可一键回退 | 跑通后 500 真的从 1000 移到 1001。回退需反向冲销(等量 `iss-tr-ins` 反向移库),**不得直接改 `LocationDetail`**。执行前确认样本可消耗 |
+| **U-D** | `op=PROC` 是通道级新能力 | `MdpDbPushExecutor` 被 WP3/WP4/WP7/WP9 共用。虽只新增独立分支,但后续任何 action 都能调白名单里的 SP。**白名单变更须评审**,建议在代码注释与 `00B` §7.2 例外中同时写明 |
+| **U-E** | 不良品(`1004`)分支未实测 | 缺样本。P5 若无法覆盖,在留证中明确标记「不良品路径未验证」,不得写成已通过 |
+| **U-F** | 父书 U8(`IsPurOrdRctTemp` 若被启用) | 启用采购暂收后 `@IsRctTemp=1`,IQC 会自行触发收货过账与上架任务,本方案的 QcCheck 过账将**重复上架**。`IqcWmsResultPushService` 入队前应判断该配置或 `PurOrdDetail.ReceiptQty`,`>0` 时不入队 PROC 消息 |
+
+---
+
+## 10. 交付包(派发时必须一并给出)
+
+1. 本书(全文)
+2. 父书 [`S5来料检验IQC链路打通方案与执行任务书.md`](./S5来料检验IQC链路打通方案与执行任务书.md) 全文 —— **注意其 Q3/Q4/§6.4/§6.5/§11.2/§11.8 已被本书 §3 推翻,遇冲突以本书为准**
+3. [`00B-执行须知(模型输入契约)`](./旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md) —— 其 §7.2 的例外见本书 §3 Q9
+4. [`03-P1P2P3运行验收.md`](./UAT留证/2026-08-11-IQC-P0取证/03-P1P2P3运行验收.md)、[`10-P4端到端留证.md`](./UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md)
+5. 165 与 `aidopdev` 的连接(密码带外注入,不得入库入档)
+
+**执行者硬性须知**:
+
+- 旧 DOP 已停运,任何步骤不得要求在旧 DOP 上操作
+- 本书所有 165 行为结论都来自定义原文实测,**不要按变量名/注释推断**;需复核时按 §2 的 SQL 重跑
+- 遇到「与父书结论矛盾」时,先看本书 §3 是否已显式推翻;未推翻的部分父书继续有效

+ 398 - 0
doc/plan/S5-IQC热回读覆盖本地自有字段修复执行任务书.md

@@ -0,0 +1,398 @@
+# S5 · IQC 热回读覆盖本地自有字段修复执行任务书
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 让「认领 → 生成检验单」在真人界面操作节奏下可用:热回读不得再用 165 的空值/旧值覆盖 Ai-DOP 自有字段(认领人、优先级、检验起止、检验状态) |
+| 编写日期 | 2026-08-11 |
+| 状态 | **已落地(2026-08-11)**:后端 `1.0.334`;P1/P1-b/P2/P3/P4 已合入;界面 D1 验收见留证 `20` |
+| 触发场景 | 2026-08-11 21:22–21:25 真人 UAT:任务列表点「认领」成功(负责人列显示 `Demo01`),十几秒后点「来料检验」报 **`尚未认领或非认领人操作!`** + HTTP 400 |
+| 父任务书 | [`S5来料检验IQC链路打通方案与执行任务书.md`](./S5来料检验IQC链路打通方案与执行任务书.md)(下称**父书**) |
+| 关联 | [`S5-IQC合格上架过账(QcCheck)执行任务书.md`](./S5-IQC合格上架过账(QcCheck)执行任务书.md)、[`旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md`](./旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md)、[`UAT留证/2026-08-11-IQC-P0取证/`](./UAT留证/2026-08-11-IQC-P0取证/README.md) |
+| 活样本 | PO `PO202608110003` 行 2 / 收货单 `RC202608110002` / 批 `260811014` / 物料 `81HC0854` / 箱码 `10003232-20260811-00010020001`(500)/ 报检单 `RQLLJYSQ202608110002` / 分录 `459268028740785` / Domain `8010` / 租户 `797403760988229` / 操作人 `AIDOPDemo`(UserId `798578309906501`) |
+| 后端现值 | `1.0.333`,本书落地后升 `1.0.334` |
+
+---
+
+## 1. 结论摘要
+
+**两个独立缺陷叠加,导致「认领」的有效期只有十几秒。**
+
+| # | 缺陷 | 一句话根因 | 严重度 |
+|---|------|-----------|--------|
+| D1 | 认领人被抹除 | IQC 分录回读的 `ON DUPLICATE KEY UPDATE` 把 `jyfzr = VALUES(jyfzr)` 无条件写回,而 165 侧 `jyfzr` 恒为 `NULL` → 每次回读都把认领人清空 | **阻断**:界面无法完成「认领 → 生成检验单」 |
+| D2 | 回读无谓重放 | 快照哈希在 165 数据完全没变的情况下每轮都变(已两次采样证实),于是每个轮次都判为「有变化」并重放整套 UPSERT,把 D1 的偶发放大成必然 | 高:放大 D1;且持续无效写库与远端查询 |
+
+D1 是**语义错误**(把 Ai-DOP 独占字段当成 165 权威字段回读),D2 是**机制缺陷**(快照指纹不稳)。**D1 必须修**;D2 修掉后 D1 的暴露概率才归零,但只修 D2 不修 D1 仍会在 165 真有变更时(例如同 PO 再收一箱)复现。
+
+第一轮(`…0010010001`)之所以没暴露:当时是用 API 连续调 `claim` → `generate-inspbill`,两次调用间隔不到 1 秒,抢在回读重放之前完成了。
+
+---
+
+## 2. 复现步骤(真人路径,100% 复现)
+
+前置:后端 `1.0.333` 运行中,HotWatch 对 `PO202608110003` 的关注为启用(`ado_mdp_hot_watch.id=18`,`status=0`)。
+
+1. 165 WMS APP「采购收货」扫箱码 `10003232-20260811-00010020001` 收货 → 165 自动建 `RC202608110002` 与报检单 `RQLLJYSQ202608110002`
+2. Ai-DOP 前端 `#/aidop/s5/iqc/task-list` →「待检任务」页签 → 按报检单号查出该行
+3. 勾选该行 → 点「认领」→ 提示成功,**负责人列显示 `Demo01`**(说明 `jyfzr` 已写入本库)
+4. **等 15 秒以上**(等一轮回读)→ 点行尾「来料检验」
+5. 报错 `尚未认领或非认领人操作!`,HTTP 400
+
+对照实验:第 3 步之后**立刻**(1 秒内)点第 4 步 → 成功建单。这条对照是判定 D1 的关键。
+
+---
+
+## 3. 取证(全部可复现,采集于 2026-08-11 21:20–21:36)
+
+### 3.1 报错来自哪一行
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcTaskEntryService.cs` §241–242:
+
+```csharp
+        if (!string.Equals(entry.OwnerId?.Trim(), userId, StringComparison.Ordinal))
+            throw Oops.Oh("尚未认领或非认领人操作!");
+```
+
+`entry.OwnerId` 取自 `qms_qcp_insappnentry.jyfzr`(同文件 §227 的 SELECT)。`claim` 写的正是同一列、同一个值(`_userManager.UserId.ToString()`),两侧取值口径一致,**所以报错只可能是该列被清空或被改写**。
+
+### 3.2 本库该列现值:已被清空
+
+```sql
+-- aidopdev
+SELECT id, jyfzr, LENGTH(jyfzr) AS len, FINSPECTSTATUS, tenant_id
+  FROM qms_qcp_insappnentry WHERE id = 459268028740785;
+-- 实测(真人点过认领之后):jyfzr = NULL, len = NULL, FINSPECTSTATUS = '未检验'
+```
+
+### 3.3 回读 UPSERT 无条件覆盖(代码原文)
+
+`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs` §716–737:
+
+```sql
+                    ON DUPLICATE KEY UPDATE
+                        glid = VALUES(glid),
+                        ...
+                        FINSPECTSTATUS = VALUES(FINSPECTSTATUS),
+                        jyfzr = VALUES(jyfzr),
+                        yxj = VALUES(yxj),
+                        jykssj = VALUES(jykssj),
+                        jywcsj = VALUES(jywcsj),
+                        ...
+                        tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)
+```
+
+注意最后一行 `tenant_id` 已经用了「本地有值就不覆盖」的守护写法——说明这个模式在本文件里已有先例,本次是把同样的保护补到业务自有列上,不是引入新范式。
+
+### 3.4 165 侧这些列的真实取值(决定归属的关键事实)
+
+```sql
+-- dopdemorq
+SELECT id, jyfzr, yxj, FINSPECTSTATUS, jykssj, jywcsj
+  FROM qms_qcp_insappnentry WITH(NOLOCK)
+ WHERE id IN (459268028740784, 459268028740785);
+```
+
+| 分录 | `jyfzr` | `yxj` | `FINSPECTSTATUS` | `jykssj` | `jywcsj` |
+|---|---|---|---|---|---|
+| `…784`(第一轮,已判合格并回写) | **NULL** | `420171544903749` | `检验完成` | NULL | `2026-08-11 16:52` |
+| `…785`(第二轮,未检验) | **NULL** | `420171544903749` | `未检验` | NULL | NULL |
+
+三条结论:
+
+1. `jyfzr` 在 165 恒为 `NULL`——旧 DOP 的检验负责人是在低代码表单里另存的,**165 这张表从来没有认领人**。回读它等于回读 `NULL`。
+2. `yxj` 在 165 有值,但那是**低代码字典 id(`420171544903749`)**,不是 Ai-DOP 的优先级语义(`adjustIqcTaskPriority` 写的是小整数)。这也解释了任务列表「优先级」列里那串刺眼的长数字——它是 165 的字典 id 被回读进来的,不是我们的优先级。
+3. `FINSPECTSTATUS` / `jywcsj` 是**双方都会写**的列:Ai-DOP 建单时置「检验中」、判定后置「检验完成」并推 165;165 侧在旧链路里也会写。所以这两列不能简单「不回读」,要防回退。
+
+### 3.5 D2:快照哈希每轮都变,而 165 数据没变
+
+**165 侧稳定**——把服务端六段快照查询原样搬到 165 上跑,间隔 35 秒采两次,六个哈希全部一致:
+
+```sql
+-- dopdemorq,两次采样结果完全相同
+SELECT 'PurOrdMaster' AS T, CONVERT(varchar(64), HASHBYTES('SHA2_256',
+         ISNULL((SELECT * FROM PurOrdMaster WITH(NOLOCK)
+                  WHERE Domain='8010' AND PurOrd='PO202608110003' FOR JSON PATH),'')), 2) AS H
+UNION ALL SELECT 'PurOrdDetail', ...
+UNION ALL SELECT 'MissedPrint',  ...   -- WHERE Domain='8010' AND OrdNbr='PO202608110003'
+UNION ALL SELECT 'inspecapplyn', ...
+UNION ALL SELECT 'insappnentry', ...
+UNION ALL SELECT 'inspbill',     ...
+-- 实测两轮:PurOrdMaster=A165EBB1…, PurOrdDetail=F8C19B5B…, MissedPrint=48599BE8…,
+--           inspecapplyn=E8E6EE0D…, insappnentry=8EFB44E7…, inspbill=E3B0C442…(空集)
+```
+
+**服务端不稳定**——同一时段读本库快照指纹,两次不同:
+
+```sql
+-- aidopdev,间隔 96 秒
+SELECT id, last_poll_time, LEFT(last_snapshot_hash,16) AS h, NOW() FROM ado_mdp_hot_watch WHERE id=18;
+-- 21:34:22 → h = F25586BEE0E8E7A3
+-- 21:36:06 → h = 1C4EF09A3D289C4F
+```
+
+**后果**(日志原文,间隔 8–20 秒一次,持续整个 UAT 时段):
+
+```
+21:18:10 [MdpHotWatch] applied IQC purOrd=PO202608110003 domain=8010 apply=2 entry=2 bill=0
+21:18:18 [MdpHotWatch] applied IQC purOrd=PO202608110003 domain=8010 apply=2 entry=2 bill=0
+21:18:31 …  21:18:43 …  21:19:12 …  21:19:32 …  21:19:51 …  21:20:02 …
+```
+
+哈希计算位置(拼接顺序即 165 返回的行序,无任何规范化):
+
+`MdpHotWatchService.cs` §234–249:
+
+```csharp
+                var sb = new StringBuilder();
+                foreach (var table in tables)
+                {
+                    var rows = await QueryByBizKeyAsync(remote, table, watch.Domain, watch.BizType, watch.BizKey, ct);
+                    foreach (var row in rows)
+                        sb.Append(JsonSerializer.Serialize(row));
+                }
+
+                var hash = Sha256(sb.ToString());
+```
+
+而 `QueryByBizKeyAsync` 的每一段 SQL **都没有 `ORDER BY`**,且多段用了 `TOP 200` / `DISTINCT` / `WITH (NOLOCK)`(见同文件 §341–433)。**首要嫌疑是行序不稳定**(堆表扫描顺序、并行计划、`DISTINCT` 的哈希聚合顺序都可能变),但本书**不预设结论**,D2 的修法前置一张取证卡片(§5 P3-0)。
+
+已排除的另一种可能:`last_snapshot_hash` 写不进库(多租户过滤导致 0 行)——两次采样的值确实**变了**,说明持久化是通的。
+
+---
+
+## 4. 字段归属矩阵(本书的核心决策)
+
+**原则**:回读只能覆盖「165 权威」的列;「Ai-DOP 权威」的列不回读;「双方都写」的列只允许状态前进、不允许被空值或更旧的状态覆盖。
+
+| 列 | 语义 | 165 是否写 | Ai-DOP 是否写 | 权威方 | 回读策略(本书结论) |
+|---|---|---|---|---|---|
+| `jyfzr` | 检验负责人 / 认领人 | ❌ 恒 NULL | ✅ `claim` / `assign` | **Ai-DOP 独占** | **不参与回读**(INSERT、UPDATE 都不写) |
+| `yxj` | 优先级 | ⚠️ 有值但是字典 id,语义不同 | ✅ `priority` | **Ai-DOP 独占** | **不参与回读**(同上;并清理历史脏值,见 P1-b) |
+| `jykssj` | 检验开始时间 | ❌ 本例 NULL | ✅ `generate-inspbill` | Ai-DOP 为主 | 165 空值不覆盖 |
+| `jywcsj` | 检验完成时间 | ✅ | ✅ `complete` | 双方 | 165 空值不覆盖 |
+| `FINSPECTSTATUS` | 检验状态 | ✅ | ✅ 建单置「检验中」、判定后置「检验完成」 | 双方 | **只许前进**:`未检验 < 检验中 < 检验完成`,且空值/未知值不覆盖 |
+| 其余列(`glid`/`FMATERIALCFG`/`FLOTNUMBER`/`FAPPLYQTY`/`FSRCORDERNUM`/`FWAREHOUSEID`/`FLOCATIONID`/供应商/送货单…) | 165 主数据与报检事实 | ✅ | ❌ | **165 权威** | 保持现状,照旧覆盖 |
+
+Ai-DOP 写这些列的位置(执行者需据此核对,不要遗漏新增写入点):
+
+| 动作 | 位置 | 写入列 |
+|---|---|---|
+| 认领 | `IqcTaskEntryService.Claim` §141–157 | `jyfzr` |
+| 检验员调配 | `IqcTaskEntryService.Assign` §159–176 | `jyfzr` |
+| 优先级调整 | `IqcTaskEntryService.AdjustPriority` §178–193 | `yxj` |
+| 检验完成(手工兜底) | `IqcTaskEntryService.MarkComplete` §195–211 | `FINSPECTSTATUS='检验完成'`、`jywcsj` |
+| 生成检验单 | `IqcTaskEntryService.GenerateInspBill` §262–270 | `jykssj`、`FINSPECTSTATUS='检验中'` |
+
+---
+
+## 5. 执行卡片
+
+> 每张卡片都必须独立自检通过再进下一张。禁止顺手重构 HotWatch 的其他分支(父书 §11 与 `.cursor/rules/collaboration-scope.mdc`)。
+
+### P1 分录 UPSERT 字段守护(必做,D1 修复)
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs`,`ApplyIqc*` 内 `qms_qcp_insappnentry` 的 UPSERT(现 §705–760)。
+
+**改动 1 —— 从 INSERT 列表与参数里摘掉 Ai-DOP 独占列。**
+
+- INSERT 列清单(现 §707–710)删除 `jyfzr`、`yxj`
+- VALUES 占位(现 §712–714)删除 `@Jyfzr`、`@Yxj`
+- 参数绑定(现 §751–752)删除 `new SugarParameter("@Jyfzr", …)`、`new SugarParameter("@Yxj", …)` 两行
+
+**改动 2 —— `ON DUPLICATE KEY UPDATE` 段(现 §716–737)按下表改写。** 只改这 5 行,其余行原样保留:
+
+| 原文 | 改为 |
+|---|---|
+| `jyfzr = VALUES(jyfzr),` | **整行删除** |
+| `yxj = VALUES(yxj),` | **整行删除** |
+| `jykssj = VALUES(jykssj),` | `jykssj = IF(IFNULL(VALUES(jykssj),'')='', jykssj, VALUES(jykssj)),` |
+| `jywcsj = VALUES(jywcsj),` | `jywcsj = IF(IFNULL(VALUES(jywcsj),'')='', jywcsj, VALUES(jywcsj)),` |
+| `FINSPECTSTATUS = VALUES(FINSPECTSTATUS),` | 见下方状态前进写法 |
+
+状态只许前进(`FIELD` 对 NULL 与枚举外的值返回 `0`,因此下式天然覆盖「空值不覆盖」):
+
+```sql
+FINSPECTSTATUS = IF(
+    FIELD(VALUES(FINSPECTSTATUS), '未检验', '检验中', '检验完成') > 0
+    AND FIELD(VALUES(FINSPECTSTATUS), '未检验', '检验中', '检验完成')
+        >= FIELD(FINSPECTSTATUS, '未检验', '检验中', '检验完成'),
+    VALUES(FINSPECTSTATUS), FINSPECTSTATUS),
+```
+
+**注释要求**:只在 `FINSPECTSTATUS` 上方写一行说明「状态只许前进,165 的旧值/空值不得打回;`jyfzr`/`yxj` 为 Ai-DOP 独占故不回读」。不要逐行注释 SQL。
+
+**自检**:
+1. `dotnet build` 通过
+2. `MdpHotWatchService.cs` 内 `jyfzr`、`yxj` 只应在**注释**中出现,代码中不再出现(`rg "jyfzr|yxj" MdpHotWatchService.cs` 核对)
+3. 全文再搜一次 `VALUES(` 确认没有误删其他列
+
+### P1-b 历史脏值清理(可选,判据先行)
+
+回读进来的 `yxj = 420171544903749` 是 165 字典 id,会让「优先级」列显示长数字。**先查再决定**,不要盲改:
+
+```sql
+-- aidopdev:确认脏值范围(Ai-DOP 的优先级是小整数)
+SELECT yxj, COUNT(*) FROM qms_qcp_insappnentry GROUP BY yxj;
+```
+
+若确认只有「小整数」与「165 字典 id」两类,再执行(**须经负责人确认后**):
+
+```sql
+UPDATE qms_qcp_insappnentry SET yxj = NULL WHERE yxj > 1000;
+```
+
+出现第三类取值时**停手**并回报,不得自行推断。
+
+### P2 检验单 UPSERT 同类风险(必做取证,改动视判据)
+
+`qms_qcp_inspbill` 的回读 UPSERT(现 §772–802)同样无条件覆盖 `dhsl`(合格数)、`bhgsl`(不合格数)、`pd`(判定)、`clfs`(处理方式)、`FBILLSTATUS`、`jyr`、`FINSPECTORID`、`FINSPESTARTDATE`。这些列由 Ai-DOP 的 `submit-result` 写入并推 165,**若推送滞后或失败,回读会把本地已录的检验结果打回 0/空**。
+
+**判据**:
+
+```sql
+-- 165:本地推上去的检验单是否已存在、值是否与本地一致
+SELECT FBILLNO, lydjbh, hid, dhsl, bhgsl, pd, clfs, FBILLSTATUS
+  FROM qms_qcp_inspbill WITH(NOLOCK) WHERE lydjbh = 'RQLLJYSQ202608110002';
+-- aidopdev:同一张单的本地值
+SELECT FBILLNO, lydjbh, hid, dhsl, bhgsl, pd, clfs, FBILLSTATUS
+  FROM qms_qcp_inspbill WHERE lydjbh = 'RQLLJYSQ202608110002';
+```
+
+- 两侧一致 → 本轮**不改**,在本书记一笔「已知风险,待 `IQC_WMS_BILL` 推送失败场景复现后再处理」
+- 165 侧缺行或数量为 0/NULL 而本地有值 → 按 P1 同款守护改写这 8 列(数值列判据用 `VALUES(x) IS NULL`,不要用 `=0` 当空值,`0` 是合法的不合格数)
+
+### P3 快照重放治理(D2)
+
+#### P3-0 取证(先做,不许跳)
+
+在 `RunOnceAsync` 的哈希比较处(现 §242–249)临时加 `LogDebug`,打出每张表的 `rows.Count` 与该表片段的 SHA256;连续观察 3 轮无 165 变更的轮次,定位是**哪张表**的片段在变,并区分:
+
+- 行数相同、片段哈希变 → 行序或列值不稳(若逐行哈希集合相同则确定为**行序**问题)
+- 行数变 → 165 侧确有变更(那就不是 D2,转为核对 165 数据)
+
+取证结论写入 `doc/plan/UAT留证/2026-08-11-IQC-P0取证/`,取证日志代码在 P3-1 落地后删除。
+
+#### P3-1 修法(取证确认为行序问题时)
+
+**推荐(一处改动覆盖全部 biz_type)**:哈希前对行的序列化结果做序无关规范化——
+
+```csharp
+var parts = new List<string>();
+foreach (var table in tables)
+{
+    var rows = await QueryByBizKeyAsync(remote, table, watch.Domain, watch.BizType, watch.BizKey, ct);
+    foreach (var row in rows)
+        parts.Add(JsonSerializer.Serialize(row));
+}
+parts.Sort(StringComparer.Ordinal);
+var hash = Sha256(string.Concat(parts));
+```
+
+**备选**:给 `QueryByBizKeyAsync` 的每段 SQL 补确定性 `ORDER BY`(主键或业务键)。可读性好但要改 10+ 段 SQL,且 `DISTINCT + SELECT *` 组合下仍需逐条验证,工作量与风险都更大。
+
+⚠️ **上线一次性影响**:无论哪种修法都会改变全部 watch 的哈希口径,首轮必然把所有在监控业务判为「有变化」并重放一次 UPSERT。该重放是幂等的(全是 UPSERT),可接受,但必须在发布说明里写明,并在发布后确认第二轮起 `changed=0`。
+
+### P4 版本与文档
+
+1. `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>` 三处同步升到 `1.0.334`(前端未改则**不**动 `Web/package.json`)
+2. 父书补一条「回读字段归属」小节,指向本书 §4
+3. `旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md` 补「回读不得覆盖 Ai-DOP 自有字段」的通用约束(不止 IQC,后续任何新增回读表都适用)
+4. `旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md` 增加一条硬性要求:**新增回读表时必须先出字段归属矩阵**,否则不得写 UPSERT
+5. 留证目录补 `20-认领被回读覆盖修复验收.md`
+
+---
+
+## 6. 验收方案
+
+### 6.1 单点验收(D1)
+
+用活样本分录 `459268028740785`(若已建单则改用同 PO 未收的 `…0020002` 重走一遍收货):
+
+| # | 步骤 | 判据 |
+|---|------|------|
+| 1 | 界面点「认领」 | 负责人列显示 `Demo01`;`SELECT jyfzr FROM qms_qcp_insappnentry WHERE id=…` = `798578309906501` |
+| 2 | **等 60 秒以上**(至少 3 轮回读),期间盯后端日志 | `jyfzr` 仍为 `798578309906501`(修复前此处会变 NULL) |
+| 3 | 点「来料检验」 | 生成 `IQC202608110003`,**不再报** `尚未认领或非认领人操作!` |
+| 4 | 建单后再等 60 秒 | `FINSPECTSTATUS` 保持 `检验中`(修复前会被打回 `未检验`);`jykssj` 不被清空 |
+| 5 | 调一次优先级为 `3`,等 60 秒 | `yxj` 保持 `3`(修复前会变回 `420171544903749`) |
+
+### 6.2 回归验收(不能把 165 权威字段挡死)
+
+在 165 上对该分录做一次真实变更,确认仍能回读进来:
+
+```sql
+-- 165(只在 UAT 环境、只对本活样本做):把状态推进一档
+-- 由执行者用 Ai-DOP 的 submit-result 正常触发,不要手工改 165
+```
+
+判据:Ai-DOP 提交合格 → 回写 165 置 `检验完成` → 下一轮回读后本库仍是 `检验完成`(前进方向不被挡)。反向用例:165 若为 `未检验` 而本地 `检验完成`,回读后本地**保持** `检验完成`。
+
+### 6.3 D2 验收
+
+- 无 165 变更时连续观察 5 分钟:日志中 `applied IQC purOrd=…` 出现 **0 次**,`polled=N changed=0`
+- `ado_mdp_hot_watch.id=18` 的 `last_snapshot_hash` 两次采样一致
+- 165 上真收一箱货后:10 秒内出现**一次** apply,随后回到 `changed=0`
+
+### 6.4 端到端复测(用剩余箱码,纯界面操作)
+
+`…0020002`(500)或 `…0020003`(251)走完整链路:APP 采购收货 → 申请列表出单 → 认领 → 来料检验 → 录 500/0 合格 → 提交。判据沿用 QcCheck 任务书 §7:`mdp_outbox` 5 条 `status=1`、箱码 `I→N` 且库位 `1000→1001`、`LocationDetail` 目标库位 `AvailStatusQty` 到账、`InvTransHist` 两行 `iss-tr-ins`。
+
+---
+
+## 7. 回滚
+
+单文件回滚即可:`git checkout -- server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs` 并把版本号退回 `1.0.333`。无数据库结构变更、无迁移脚本;P1-b 若执行过 `yxj` 清理,回滚不恢复该数据(清理前须先导出原值)。
+
+---
+
+## 8. 范围边界(本轮明确不做)
+
+- 不给 HotWatch 引入「字段归属配置表」之类的通用机制——本轮只按 §4 的矩阵硬编码 IQC 分录这一处
+- 不动 `qms_qcp_inspecapplyn`(报检主表)的 UPSERT:该表全部列均为 165 权威,Ai-DOP 不写
+- 不改 `IqcTaskEntryService` 的认领人校验逻辑(校验本身是对的,别为了绕过问题去放宽它)
+- 不动工单 / 排产 / 领料 / PSD 等其他 biz_type 的 apply 分支
+- 不处理「多人抢认领」的并发语义(现状是后点者覆盖,属既有设计,另议)
+
+---
+
+## 9. 跨模块影响
+
+| 模块 | 影响 | 说明 |
+|---|---|---|
+| S5 来料检验 | 直接受益 | 认领、优先级、检验状态不再被抹 |
+| **全部热回读业务(S2 排产、S3 供需、S5 领料、工单执行、PSD)** | **仅 P3-1 会波及** | 改哈希口径会让所有 watch 重放一次 UPSERT(幂等)。P1 只改 IQC 分录 UPSERT,不波及其他 biz_type |
+| 数据中台监控 | 轻微 | D2 修复后 `changed` 计数与贴源写入频率会明显下降,看板上是「变更变少」而非故障 |
+| 165 | 无 | 本次不新增任何对 165 的写入;P3-1 备选修法只加 `ORDER BY`,仍是只读查询 |
+
+P3-1 属于跨模块改动,**必须在执行前单独向负责人确认**;P1 不跨模块,确认本书后即可执行。
+
+---
+
+## 10. 执行须知
+
+1. **改完必须重启后端**才生效,而本环境启动时会做 256 张实体的表结构初始化,**约 10 分钟**才开始监听端口。留出时间窗,别在等待期误判为启动失败。
+2. 重启前确认**没有其他后端实例在跑**(`Get-Process Admin.NET.Web.Entry`)。历史上出现过旧实例抢消费 outbox 导致「新代码没生效」的误判(见 QcCheck 任务书 §9)。
+3. 验收 D1 的第 2 步**必须真的等够 3 轮回读**。修复前后的差别只在「时间窗」上,等不够就会得到假阳性结论。
+4. MCP 的 SQL 通道只允许只读语句,`DECLARE` 也会被拦。取证 SQL 请把变量内联成字面量。
+5. MySQL 的 `VALUES()` 在 8.0.20 起标记为废弃,但本文件其余 UPSERT 都在用,本次**保持一致**,不要顺手迁移到 `AS new` 别名语法。
+
+---
+
+## 附录 A:不改代码继续 UAT 的临时绕过
+
+三种都可行,任选:
+
+1. **抢时间窗**:点「认领」后**立刻**(1 秒内)点「来料检验」
+2. **API 连调**:`POST /api/S5IqcTaskEntry/claim {"ids":[459268028740785]}` 紧接 `POST /api/S5IqcTaskEntry/generate-inspbill {"entryId":459268028740785}`
+3. **临时停掉该 watch**:把 `ado_mdp_hot_watch.id=18` 的 `enabled/status` 置为停用,做完认领与建单再恢复(**注意**:停用期间该 PO 的 165 变更不会回读,收货类测试期间不要用这招)
+
+建单成功后就不再受本缺陷影响:`submit-result` 全程不校验 `jyfzr`(`IqcInspBillFlowService` 中无该列任何引用)。
+
+## 附录 B:本轮活样本与剩余余量
+
+| 项 | 值 |
+|---|---|
+| 已消耗 | `…0010010001`(第一轮,已上架至 `1001`)、`…0010020001`(第二轮,已收货待检,报检单 `RQLLJYSQ202608110002`) |
+| 可用余量 | 行 1:`…0010010002`(500)、`…0010010003`(67);行 2:`…0010020002`(500)、`…0010020003`(251) |
+| 待检库 / 合格库 / 不良库 | `1000` / `1001`(`ItemMaster.Location`)/ `1004`(`PurOrdControl.RejectInspectionLocation`) |
+| 扫码用图 | `_tmp_uat_codes/qr-carton-*.png`、`bar-carton-*.png`(按需用 `_tmp_gen_uat_codes.py` 同款方式补生成) |

+ 728 - 0
doc/plan/S5来料检验IQC链路打通方案与执行任务书.md

@@ -0,0 +1,728 @@
+# S5 来料检验(IQC)链路打通方案与执行任务书
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 让「扫码收货 → 自动报检 → IQC 检验合格 → 上架入库」在 **Ai-DOP** 里可见、可操作、可闭环 |
+| 编写日期 | 2026-08-11 |
+| 状态 | **决策全部完成;P0–P3 代码已落地(2026-08-11)**:后端 `1.0.332` / 前端 `2.4.284`。待重启后端后做 P4 端到端验收(认领→建单→提交→APP 上架) |
+| ⚠️ 前提变更 | **旧 DOP 已停运,仅作移植参考,不得要求任何旧 DOP 上的操作**(2026-08-11 确认)。由此:① 原 P0「在旧 DOP 跑一次真实检验」作废,改为从 165 低代码元数据静态取证,已完成;② §5 原「回退到旧 DOP Web 判定」的退路**不存在**,方案 B 是唯一可行路径 |
+| 触发场景 | UAT 实测:`PO202608110003` / 收货单 `RC202608110001` / 批号 `260811013` / 500 件 |
+| 关联文档 | [`旧DOP-MES-WMS/对接任务书/00-总体方案.md`](./旧DOP-MES-WMS/对接任务书/00-总体方案.md) §4.3、[`WP3-回写执行器`](./旧DOP-MES-WMS/对接任务书/WP3-回写执行器.md)、[`WP8-实时回读热链路`](./旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md)、[`附录A-手册操作覆盖矩阵`](./旧DOP-MES-WMS/对接任务书/附录A-手册操作覆盖矩阵.md) §4.1 |
+
+---
+
+## 1. 结论摘要
+
+**Ai-DOP 的 IQC 功能不是没做,是三处断开**:
+
+| # | 断点 | 现状 | 后果 |
+|---|------|------|------|
+| **B1** | 165 的报检单/检验单没有回读到本库业务表 | `aidopdev.qms_qcp_inspecapplyn` / `qms_qcp_inspbill` 各只有 6 条 UAT 造数;真实报检单 `RQLLJYSQ202608110001` 只在 165 | Ai-DOP 三个 IQC 页面看不到这批 500,判定无从下手 |
+| **B2** | 本库没有「报检单 → 检验单」的生成动作 | 任务列表直读 `qms_qcp_inspbill`,无派工/建单入口 | 即使报检单回读进来,任务列表仍为空 |
+| **B3** | 合格回推打在占位目标上 | `TryEnqueueIqcPassOutboxAsync` 写 `target_source_code='QMS_API'`、`path='/iqc/result'`;`mdp_source.QMS_API` 的 `status=0` | 在 Ai-DOP 判合格,165 侧箱码不会解除待检,货放不出来 |
+
+**已有的部分比预想完整**:后端 `IqcInspBillFlowService`(`api/S5IqcInspBillFlow`)已实现检验员提交结果、主管通过/退回、SQE 处置四个动作与审批流;前端 `iqcTaskList.vue` 的详情抽屉已接这四个动作;再往下还有 L0/L1 合格入库过账骨架(`IqcReceiptStateMdpSyncService`、`IqcInventoryEventConsumer`、`AdoIqcInventoryPosting`)。**缺的是数据进出,不是业务逻辑。**
+
+**打通方案与既有架构定案冲突,Q1 已决为方案 B**(见 §4)。
+
+**旧 DOP 停运使这件事从「优化」变成「必做」**:IQC 判定原本只在旧 DOP Web 上有入口(165 的 APP 菜单 `type=1` 里没有判定录入项,见 §2.1),旧 DOP 停运后该动作**当前无任何系统可执行**——待检库的货只能进不能出。因此本方案不是三个候选之一,而是唯一出路,`§4` 的方案 A(守定案、判定留在旧 DOP)已随之失效。
+
+---
+
+## 2. 取证基线(2026-08-11 实测)
+
+以下均为直连两库实测,非推断;标注「推断」者需 P0 取证。
+
+### 2.1 165(dopdemorq)侧
+
+| 对象 | 实测值 |
+|------|--------|
+| 报检单 `qms_qcp_inspecapplyn` | `FBILLNO=RQLLJYSQ202608110001`、`FBILLTYPE=来料检验申请`、`FAPPLYTIME=2026-08-11 13:09:37`、`FQUALITYORG=NULL`、`FINSPECORGID=NULL` |
+| 报检分录 `qms_qcp_insappnentry` | `FMATERIALCFG=81HC0744`、`FLOTNUMBER=260811013`、`FAPPLYQTY=500`、`FSRCORDERNUM=RC202608110001`、`FSRCORDERTYPE=po`、`shdh=10003232-20260811-0001`、`FSUPPLIER=10003232`、`FWAREHOUSEID=1000`、`FLOCATIONID=01-01-01`、**`FINSPECTSTATUS=未检验`**、`FINSPEDEPTID/FINSPECTORID/jyfzr=NULL` |
+| 检验单 `qms_qcp_inspbill` | **0 行**(尚未派工) |
+| 收货明细 `PurOrdRctDetail` | `RecID=48656`、**`RctType='rc'`**、`Receiver=RC202608110001`、`OrdNbr=PO202608110003`、`OrdLine=1`、`RctQty=500`、`QtyReceived=500`、**`QCNbr=''`、`StatusByQC=''`**、`Location=1000`、`InvShelf=01-01-01`、`IsChanged=0`、`Potype=po` |
+| 采购明细 `PurOrdDetail` | `RctQty=500`、**`ReceiptQty=0`**、`QtyReturned=0` |
+| 箱码 `MissedPrint` | `...0010001`:`Status='I'`、`RctNbr=RC202608110001`、`Qty=500`;另两箱 `...0010002`(500)、`...0010003`(67) 未收货、`RctNbr=''` |
+| 库位 | `1000=待检库`、`1001=合格库`;`LocationShelfMaster` 存在 `1001/01-01-01`,**不存在 `1000/01-01-01`** |
+| 上架人员 `EmpWorkDutyMaster` | `Duty` 含 `up-shelf` 的记录 **0 行**;`MobileTask` **0 行** |
+| APP 菜单 `rf_menu`(`type=1`) | `WMS菜单` 下有 `采购收货`、`上架移库`、`检验后部分收货`、`检验确认`、`IQC留样`;**无 IQC 判定录入入口** |
+| Web 菜单 `rf_menu`(`type=0`) | `数字化运营 > S5物料仓储 > 来料检验 > {来料检验申请列表 / 来料检验任务列表 / 来料检验结果列表}`;另有等价老路径 `质量管理 > 来料质量管理` |
+
+### 2.2 旧 DOP 的 QMS → WMS 写回链路(存储过程取证)
+
+`qms_WMS_SaveIQCResult @id`(`@id` = `qms_qcp_inspbill.id`)是唯一的桥,关联链为:
+
+```
+qms_qcp_inspbill a
+  ⋈ (a.lydjbh = b.FBILLNO)      qms_qcp_inspecapplyn b
+  ⋈ (b.id = c.glid AND a.hid = c.id)  qms_qcp_insappnentry c
+  ⋈ ('u_' + rf_user.id = a.jyr)  rf_user d
+```
+
+**完整五分支判定矩阵**(`pd`=判定 0合格/1不合格,`clfs`=处理方式;`dhsl`=检验数量,`bhgsl`=不合格数量。2026-08-11 逐分支读全过程定本,P3 照此实现):
+
+| # | 分支条件 | `@QCStatus` | `@MRBStatus` | `@QtyAcc` | `@QtyReject` |
+|---|---------|-------------|--------------|-----------|--------------|
+| 1 | `pd=0` 且 `clfs` 空 —— 合格 | `YES` | 0 | `dhsl` | 0 |
+| 2 | `pd=0` 且 `clfs='3'` —— 合格但挑选 | `NO` | 1 | `dhsl` | `bhgsl` |
+| 3 | `pd=1` 且 `clfs='0'` —— 不合格退货 | `NO` | 2 | 0 | `dhsl+bhgsl` |
+| 4 | `pd=1` 且 `clfs='2'` —— 不合格让步接收 | `NO` | 3 | `dhsl+bhgsl` | 0 |
+| 5 | `pd=1` 且 `clfs='3'` —— 不合格挑选 | `NO` | 1 | `dhsl` | `bhgsl` |
+
+五分支恒定传 `@Domain='8010'`、`@Barcodes=''`、**`@Shelf=''`**;五条都不匹配时(如 `pd=1` 且 `clfs='1'`)过程直接返回 `Success=1` 放行,不写任何东西。
+
+五个分支都在成功后写 `UPDATE qms_qcp_insappnentry SET FINSPECTSTATUS='检验完成', jywcsj=convert(varchar(20),getdate(),120) WHERE id=a.hid`。
+
+> **注意 `MRBStatus` 语义与 §2.3 的入口校验注释不一致**:过程注释写 `{0 退货, 1 挑选使用, 2 退货, 3 让步接收}`,而实际调用是 `0=合格放行`、`1=挑选`、`2=退货`、`3=让步接收`。**以本矩阵的实际调用为准**,注释不可信。
+
+**合格分支不传货架**(`@Shelf=''`):上架是独立动作,由 WMS APP 的「上架移库」完成,或由自动推送的 `MobileTask` 承接。
+
+**写回时机(决定 P3 挂点,2026-08-11 定论)**:这对存储过程挂在检验单工作流(`rf_flow.id=414211110453317`「来料检验流程」,`status=1`)的**检验员填写节点**上,不在领导审批节点:
+
+```
+submitBefore : [sql][q]exec      qms_WMS_SaveIQCcheck  @id='{<InstanceId>}'   → 预检,内部调 qms_WMS_SaveIQCResultcheck,返回 ResultCode
+submitAfter  : [sql-json][q]exec qms_WMS_SaveIQCResult @id='{<InstanceId>}'   → 真写,内部调 pr_WMS_SaveIQCResult
+```
+
+`{<InstanceId>}` 即 `qms_qcp_inspbill.id`(低代码平台的流程实例主键就是业务主表主键)。两支过程的五分支结构逐字一致,差别只在调预检版还是真写版。
+
+**由此可知**:① 旧系统是「检验员提交检验单即写 WMS」,领导审批只走流程;② `FINSPECTSTATUS='检验完成'` 是这对过程自动写的,任务列表上那个「检验完成」按钮只是手工兜底;③ 下游「领导审批」节点的两个按钮脚本分别只是 `completed()` 与 `send()`,**不碰 WMS**——所谓「合格入库」按钮只是结束流程,不做入库。
+
+### 2.3 `pr_WMS_SaveIQCResult` 的硬校验与写入范围
+
+**定位方式(只用三个参数定位,P3 必须保证这三者在 165 侧对得上)**:
+
+```sql
+-- 过程内部构造 @tbPurOrdDetails 的原文(2026-08-11 读取)
+select d.RecID, p.RecID, d.OrdNbr, d.OrdLine, p.Potype, d.QtyReceived,
+       IsNull(p.ReceiptQty,0), d.RctQty, d.QCNbr, IsNull(p.Op,0), IsNull(d.Location,''), d.Delivery
+  from PurOrdRctDetail d with(nolock)
+  left join PurOrdDetail p with(nolock)
+    on d.Domain=p.Domain and d.OrdNbr=p.PurOrd and d.OrdLine=p.Line
+ where d.Domain=@Domain and d.Receiver=@Receiver and d.ItemNum=@ItemNum
+```
+
+即定位键 = `Domain + Receiver(收货单号)+ ItemNum(物料号)`,**粒度是「收货单 + 物料」,不是箱码、不是行号**。派生变量:
+
+| 变量 | 来源 | 本例实测值 | 作用 |
+|------|------|-----------|------|
+| `@QtyReceived` | `sum(PurOrdRctDetail.QtyReceived)` | 500 | 数量平衡校验 |
+| `@RctQCNbr` | `max(PurOrdRctDetail.QCNbr)` | `''` | **幂等判据**:非空即拒 |
+| `@IsRctTemp` | `max(case when PurOrdDetail.ReceiptQty>0 then 1 else 0 end)` | **0** | 门控收货过账与上架任务 |
+| `@IsCommission` | `max(case when PurOrdDetail.Potype='PW' then 1 else 0 end)` | 0 | 委外标记 |
+
+入口校验(踩到即拒,P3 入队前必须自行预检,否则消息会在 Outbox 里反复失败):
+
+- 定位不到收货记录 → 「该收货单号(x)没有找到符合的收货记录」
+- `PurOrdRctDetail` join 不到 `PurOrdDetail`(`PurOrdRecID is null`)→ 「没有找到对应的采购单明细」。**上一轮已把自建单的 `PurOrdMaster`/`PurOrdDetail` 推入 165,此条才得以满足;`Domain`/`PurOrd`/`Line` 三者必须严格对齐**
+- `@QCStatus` 只接受 `YES`/`NO`
+- `YES` 时 `@QtyAcc>0` 且 `@QtyReject=0`
+- 该收货单该物料已有 `QCNbr` → 报「已有检验单号,不要重复推送」(`@PartRctFlag<>2` 时)
+- `@QtyAcc + @QtyReject` 必须等于 `sum(QtyReceived)`;`@PartRctFlag=2`(挑选)时改为校验 `@QtyAcc = @RctQty`
+- 箱码总数校验:`MissedPrint`(`RctNbr=@Receiver`、`ItemNum`、`Status<>'C'`、`Type<>'Card'`)的 `sum(Qty)` 必须等于收货数
+
+写入范围(关键:受 `@IsRctTemp` 门控):
+
+| 写入动作 | 门控条件 | 对本例(`RctType='rc'`、`ReceiptQty=0`)是否命中 |
+|---------|---------|------------------------------|
+| `UPDATE MissedPrint SET Status/InvStatus/QtyAccN/QtyRejectN/OrdNbr/Remark` | 无门控(`@IsRctTemp=0` 时 WHERE 恒真) | ✅ 命中,`Status` 由 `I` → `N` |
+| `INSERT MissedPrintTransHist` 一行 | 同上 | ✅ 命中,`TransType='来料检验-通过'` |
+| `UPDATE PurOrdRctDetail SET RctQty/QtyReturn/Qc/QcDate/QCNbr/StatusByQC/QCDescr` | `RctType='temp'` 分支 | ❌ 不命中 |
+| `exec pr_WMS_BPM_RctPurOrdAndPurOrdByBarcode`(真正的收货过账:`PurOrdRct*`/`InvTransHist`/`LocationDetail`) | `@IsRctTemp>0 and @QtyAcc>0` | ❌ 不命中 |
+| `exec pr_WMS_BPM_AddMobileTask @Name='InvUpShelf'`(推上架任务) | 同上 | ❌ 不命中 |
+
+> **✅ 原「最大口径冲突」已于 2026-08-11 静态取证定论,不再是阻断项。** 归因修正如下(原文写的是 `RctType` 门控,**归因有误**):
+>
+> 门控变量 `@IsRctTemp` **不取自 `PurOrdRctDetail.RctType`,而取自 `PurOrdDetail.ReceiptQty`**(`max(case when ReceiptQty>0 then 1 else 0 end)`)。本例 `ReceiptQty=0` → `@IsRctTemp=0` → 收货过账与上架任务分支不命中。结论方向不变,但根因与应对完全不同。
+>
+> **165 存在两条收货路径,本例走甲,两条都是旧系统的正常设计,不是缺陷**:
+>
+> | | 甲 · 正式收货(本例) | 乙 · 暂收在检 |
+> |---|---|---|
+> | 入口 | WMS APP「采购收货」 | WMS APP「检验后部分收货」/「检验确认」 |
+> | 收货时 | 直接建正式收货行,货入待检库 1000,`ReceiptQty=0` | 先暂收,`PurOrdDetail.ReceiptQty>0` |
+> | IQC 合格的净效果 | **只有** `MissedPrint` `I→N` + `MissedPrintTransHist` 一行 | 触发完整收货过账 + 自动推上架 `MobileTask` |
+> | 入库靠什么 | 收货时已入待检库;靠 APP「上架移库」移到合格库 1001 | IQC 时才过账入库 |
+> | 对应过程 | `pr_WMS_SaveIQCResult`(本方案复刻对象) | 疑为 `pr_WMS_SaveIQCResultByRctNbr`(加密,**不在复刻范围**) |
+>
+> **对 P3 的三条硬结论**:
+>
+> 1. **`PurOrdRctDetail.QCNbr/StatusByQC` 在甲路径下旧系统本就不写**。因此 P3 **不得**「显式补写」这两列——补写等于新增旧 DOP 没有的行为,违反 `00B` §7.4 的例外范围(例外只允许复刻旧行为)。原 §6.3 矩阵中该行已据此删除。
+> 2. **判合格 ≠ 合格入库**。甲路径下 IQC 不产生 `InvTransHist`,合格入库数(`pcrksl`)必须等 WMS APP 上架移库之后回读,见 §6.1 状态机第 6–7 步。执行者若在判合格后立刻期待合格入库出数,会误判为 bug。
+> 3. **加密的 `pr_WMS_SaveIQCResultByRctNbr` 已从阻断项移除**:它属乙路径(APP 自用),本方案既不调用也不复刻,其语义未知不影响交付。原 §9 U1 据此关闭。
+
+### 2.4 Ai-DOP(aidopdev)侧
+
+| 对象 | 实测值 |
+|------|--------|
+| 页面 | `FUNC-S5-019 来料检验申请列表`(只读)、`FUNC-S5-001 来料检验任务列表`(含流程抽屉)、`FUNC-S5-002 来料检验结果列表`(空骨架,不发请求) |
+| 判定入口 | 任务列表 → 「查看」→ `IqcInspBillDetailDrawer` → `submit-result` / `approve` / `reject` / `sqe-submit` |
+| 后端 | `api/S5IqcInspBillFlow`:`submit-result` 写 `qms_qcp_inspbill.pd/dhsl/bhgsl/clfs` 并推进 N1→N2;`supervisor-approve` N2→完成后调 `TryEnqueueIqcPassOutboxAsync` |
+| 数据源 | `iqcTaskList` 直读 `aidopdev.qms_qcp_inspbill`(注释:单表直读,表无 `tenant_id` 过滤诉求,`FORGID` 全 NULL) |
+| 本库数据量 | `qms_qcp_inspecapplyn` 6 行、`qms_qcp_inspbill` 6 行,全部为 `IQC-UAT-*` / `B-IQC-APP-001` 造数 |
+| 表结构 | 两库 `qms_qcp_inspbill` 列**完全同构**(63 列同名同序),本库仅多 `tenant_id` |
+| 合格回推 | `mdp_outbox`:`target_source_code='QMS_API'`、`action_code='S5_IQC_RESULT_PUSH'`、`idem_key=FBILLNO`、`payload={path:'/iqc/result',method:'POST',body:{billId,billNo,pd,dhsl,bhgsl}}`;**`mdp_source.QMS_API.status=0`**(P-017 占位);当前该类消息 **0 条**(从未走通过一次合格闭环) |
+| 冷链入站 | `mdp_entity.S5_IQC_INSPBILL_SQLSERVER`(source 12)→ 落 `mdp_stg_iqc_pull`,remark「WP1 MES/WMS 冷链实体 disabled」;**`biz_key_expr='Domain,BillNbr'` 与表结构不符**(`qms_qcp_inspbill` 无 `Domain`、无 `BillNbr` 列)→ 既有缺陷,启用前必须修正 |
+| L0 过账骨架 | `IqcReceiptStateMdpSyncService` 注释自述「**未接线/未实库验证**,不入任何 cron/job」,且「源 B=dopdemorq 方言适配留待,dopdemorq 六表当前空、默认禁用」——**该前提已失效**,165 六表现已有真实数据 |
+| 写源 | `mdp_source.DOPDEMORQ_SQLSERVER`(id 12)`status=1`,`MdpDbPushExecutor` 已实测可写 165(本轮采购单/送货单/箱码推送已跑通) |
+
+### 2.5 旧系统 IQC 行为规格(静态取证产出,替代原 P0 变动列清单)
+
+> **取证方式**:旧 DOP 已停运,无法跑实例。改为直读 165 内的低代码平台元数据 + 存储过程定义反推行为。**本节即原 P0 卡片的产出物,P1–P3 以本节为实现基准。**
+>
+> **重要前提**:旧 DOP 的 IQC **不是 C# 微服务实现的**。旧 DOP 源码(`ZZYDOP_OLD`)全仓搜索 `qms_qcp_inspbill`、`qms_WMS_SaveIQCResult` **零命中**;实现全部在 165 的低代码元数据里,前端按钮通过 `utils.execdb('dopflow', sql)` 直接下 SQL。因此「参考旧 DOP 移植」的对象是本节,不是旧仓源码。
+
+#### 2.5.1 元数据锚点(需复查时按此定位,勿重新摸索)
+
+```
+rf_menu(type=0).applibraryid → rf_applibrary.address = /program/run/index?programid=N → rf_program(N)
+rf_program_button.programid = N                          -- 列表按钮与脚本
+rf_program.sqlstring                                     -- 列表查询 SQL
+rf_flow(414211110453317).designjson / runjson            -- 检验单工作流(含节点事件)
+rf_flow_button.scripts                                   -- 流程按钮脚本
+```
+
+| 菜单(`rf_menu.title`) | `applibraryid` | `programid` | 备注 |
+|---|---|---|---|
+| 来料检验申请列表 | 420078661939269 | 420077129764933 | 菜单 id 有两条:`420100666445893`、`672507140161605` |
+| 来料检验任务列表 | 396053775429701 | 396052925665349 | 菜单 id:`400424766808133`、`678466281668677` |
+| 来料检验单列表 / 来料检验结果列表 | 396054668427333 | 396053981880389 | 两个菜单名共用同一 program |
+| 来料检验流程(检验单表单) | — | `rf_flow.id=414211110453317` | `status=1`;`formId=395649084129349` |
+| 流程模板 type | — | 来料检验 `396760264101957`;委外生产检验 `396760317206597` | 由 `FBIZTYPE` 决定 |
+
+#### 2.5.2 真实操作序列(与原任务书假设不同,P2 据此设计入口)
+
+```
+来料检验任务列表(program 396052925665349)
+  ① 认领           → 写 jyfzr = 当前用户
+  ② 来料检验       → 校验认领人 + 幂等 → 置「检验中」→ 打开检验单工作流
+  ③ 检验单填写提交 → submitBefore 预检 → submitAfter 真写 WMS(此刻箱码解禁)
+  ④ 领导审批       → 「合格入库」= completed() 结束流程;「不合格处理」= send() 转下一节点
+```
+
+**「来料检验申请列表」(program 420077129764933)只有 添加 / 编辑 / 查看 / 删除 四个按钮,没有派工入口**——原任务书 §7 P0 步骤①「在申请列表指派检验员并生成检验任务」的描述与实际不符,派工的真实载体是任务列表的「认领」+「来料检验」。
+
+#### 2.5.3 任务列表四个按钮的确切实现(Q6=全复刻,P2 逐条对照)
+
+| 按钮 | `showtype` | 行为(原文 SQL,`{<UserId>}`/`{<UserName>}` 为平台占位符) |
+|------|-----------|--------------------------------------------------|
+| 认领 | 1 批量 | `update qms_qcp_insappnentry set jyfzr='{<UserId>}' where id in (@ids)` |
+| 检验员调配 | 1 批量 | `update qms_qcp_insappnentry set jyfzr='<表单选定人 jyfzr01>' where id in (@ids)` |
+| 优先级调整 | 1 批量 | 同构 UPDATE(字段为优先级列) |
+| 检验完成 | 1 批量 | `update qms_qcp_insappnentry set FINSPECTSTATUS='检验完成', jywcsj=convert(varchar(20),getdate(),120) where id in (@ids)` |
+| 来料检验 | 2 行级 | 见 2.5.4 |
+
+> `jyfzr` 存在**两种写法**:认领按钮写 `{<UserId>}`(用户 id),而「来料检验」按钮用 `row.jyfzr == '{<UserName>}'`(用户名)做比较,且 `qms_WMS_SaveIQCcheck` 里又按 `'u_' + rf_user.id = inspbill.jyr` 关联。**这是旧系统的不一致,P2 复刻时必须统一为一种口径(建议存用户 id),并在 §9 记录偏离。**
+>
+> 「检验完成」按钮是**手工兜底**:正常路径下该状态由 §2.2 的那对存储过程自动写。
+
+#### 2.5.4 「来料检验」按钮完整逻辑(P2 的核心规格)
+
+```
+1. 前置校验:row.jyfzr 必须等于当前用户 → 否则 alert「尚未认领或非认领人操作!」
+2. 幂等保护:select count(*) from qms_qcp_inspecapplyn a
+                inner join qms_qcp_inspbill b on a.FBILLNO=b.lydjbh
+              where a.FBILLNO=<row.FBILLNO>
+              count>0 → alert「该检验任务已经有检验单,不能重新生成!」
+3. 模板映射:FBIZTYPE='来料检验'→396760264101957;'委外生产检验'→396760317206597
+4. 置状态:  update qms_qcp_insappnentry
+                set jykssj=convert(varchar(20),getdate(),20), FINSPECTSTATUS='检验中'
+              where id=<row.id>
+5. 打开表单:/flow/run/index?flowid=414211110453317&type=<模板>&id=<分录id>&po=<FBILLNO>
+```
+
+注意第 4 步 `jykssj` 用格式 **20**(`yyyy-mm-dd hh:mi:ss`),而「检验完成」用格式 **120**——旧系统混用,P2 统一按 `120` 写即可。
+
+#### 2.5.5 检验单 → 报检单 → 收货单的关联链与字段映射(P3 取参照此)
+
+```
+qms_qcp_inspbill a                                    -- 检验单(判定结果所在)
+  ⋈ a.lydjbh = b.FBILLNO        qms_qcp_inspecapplyn b -- 报检单
+  ⋈ b.id = c.glid AND a.hid = c.id  qms_qcp_insappnentry c -- 报检分录(a.hid 指向分录 id)
+  ⋈ 'u_' + cast(d.id as varchar(50)) = a.jyr   rf_user d   -- 检验员
+where a.id = @id
+```
+
+| `pr_WMS_SaveIQCResult` 参数 | 取值来源 | 本例值 |
+|---|---|---|
+| `@Domain` | 常量 | `8010` |
+| `@Receiver` | `qms_qcp_insappnentry.FSRCORDERNUM` | `RC202608110001` |
+| `@ItemNum` | `qms_qcp_insappnentry.FMATERIALCFG` | `81HC0744` |
+| `@QCNbr` | `qms_qcp_inspbill.lydjbh` | `RQLLJYSQ202608110001` |
+| `@QtyAcc` / `@QtyReject` | 按 §2.2 五分支矩阵由 `dhsl`/`bhgsl` 算 | 500 / 0 |
+| `@UserNo` | `rf_user.account`(经 `a.jyr` 解析) | — |
+| `@Barcodes` / `@Shelf` | 恒为空串 | `''` |
+
+> **`@QCNbr` 传的是报检单号(`lydjbh`),不是检验单号**——字段名有误导性,P3 勿传 `qms_qcp_inspbill.FBILLNO`。
+
+---
+
+## 3. 已跑通的上游(本方案的前置事实)
+
+2026-08-11 已完成并提交(后端 1.0.331):
+
+1. 自建采购单推 165:`PurOrdMaster`/`PurOrdDetail`/`srm_polist_ds`/`scm_shd`/`scm_shdzb`/`scm_shdshph`/`MissedPrint`,`MdpDbPushExecutor` 支持受限 resolve 子查询解外键
+2. WMS APP 扫码收货实测通过:`PurOrdRctMaster/Detail` 建单、`InvTransHist` 落 `rct-po-ins` 入待检库 1000、箱码 `U→I`
+3. 收货结果回读:`MdpHotWatchService` 新增 `PUR_ORDER` 热关注,回读 `PurOrdDetail.RctQty/ReceiptQty/QtyReturned`、`MissedPrint.Status/RctNbr/Location/Shelf/InvStatus`,并据箱码推进 `scm_shd/scm_shdzb.shzt`
+
+**本方案要补的正是这条链的下半段。**
+
+---
+
+## 4. 与既有架构定案的冲突(Q1 已决 = 方案 B)
+
+[`00-总体方案.md`](./旧DOP-MES-WMS/对接任务书/00-总体方案.md) §4.3 数据归属划分原文:
+
+> | 执行域(收发存、标签、报工、**检验**) | **165 dopdemorq** | 新系统只入站,**不回写执行字段** |
+
+同文 §4.2 另有:
+
+> **明确不做**(经决策):不新增旧 DOP 没有的自动回写闭环;不开「业务页面直读 165」的旁路。
+
+并且 [`附录A`](./旧DOP-MES-WMS/对接任务书/附录A-手册操作覆盖矩阵.md) 把 IQC 归为「供/读 → 回读 `qms_qcp_inspbill`、`PurOrdRct*`」,[`WP5`](./旧DOP-MES-WMS/对接任务书/WP5-主链路端到端验证.md) 场景 2 第 5 步写的是「IQC 判定 → **WMS APP**」。
+
+**因此「在 Ai-DOP 里做 IQC 判定并回写 165」不是补一个缺口,而是改一条架构定案。** 两个方案对应两种定案:
+
+| | ~~方案 A · 守定案~~(**已失效**) | 方案 B · 改定案 |
+|---|---|---|
+| 判定在哪 | ~~旧 DOP Web~~ → **已停运,无处可做** | **Ai-DOP** |
+| Ai-DOP 角色 | ~~只读可见~~ | 主导判定 + 字段级回写 165 |
+| 触碰红线 | 无 | 需为「检验」一项破 §4.3「不回写执行字段」 |
+| 工作量 | ~~小(1 个卡片)~~ | 中(3 个卡片,P0 已用静态取证完成) |
+| 风险 | ~~低~~ → 实为**最高**:待检库的货无法放行 | 中(双写覆盖;原「`rc`/`temp` 口径冲突」与「加密过程语义未知」两项已于 §2.3 定论关闭) |
+| 用户体感 | ~~判定要切到旧系统~~ | 全流程在 Ai-DOP |
+
+> **Q1 已决(2026-08-11):选方案 B。** 判定权威落在 Ai-DOP,回写 165 按 §6.3 字段共管矩阵执行。
+>
+> **随之而来的强制配套动作**:修订 [`00-总体方案.md`](./旧DOP-MES-WMS/对接任务书/00-总体方案.md) §4.3,把「检验」从执行域的「不回写执行字段」中划为例外,并写明例外范围仅限 §6.3 白名单列;同时在 [`附录A`](./旧DOP-MES-WMS/对接任务书/附录A-手册操作覆盖矩阵.md) 与 [`WP5`](./旧DOP-MES-WMS/对接任务书/WP5-主链路端到端验证.md) 场景 2 第 5 步把 IQC 判定的承接方从「WMS APP」改为「Ai-DOP」。**此项不做,后续执行者会按旧定案否掉本方案**(列入 P3 交付物)。
+
+---
+
+## 5. 第一层:回读(原方案 A;Q1=B 后成为方案 B 的必备底座)
+
+> Q1 已选方案 B,本节不再是「可选方案」,而是 **P1 卡片的设计说明**:无论判定在哪一侧做,报检单都必须先回读进本库业务表,否则 Ai-DOP 无从展示、无从派工。
+>
+> ⚠️ **原「退路」条款已作废**:本节原写「若风险不可接受可暂缓 P2/P3,回退到 165 权威、判定切回旧 DOP Web」。**旧 DOP 已停运,该退路不存在**。P1 单独交付仍有价值(页面能看到真实报检单、不产生废弃代码),但**只交付 P1 等于 IQC 判定仍然无人能做、待检库的货放不出来**,不构成可接受的终态。
+
+### 5.1 范围
+
+把 165 的报检单 / 报检分录 / 检验单回读进本库**业务表**(不是只落贴源表),让三个 S5 IQC 页面显示真实数据。判定结果(无论由 Ai-DOP 还是旧 DOP 产生)都经同一条回读链路回到本库,保证两侧口径一致。
+
+### 5.2 为什么不能只启用冷链实体
+
+`S5_IQC_INSPBILL_SQLSERVER` 落的是 `mdp_stg_iqc_pull` 贴源表,而页面读的是业务表 `aidopdev.qms_qcp_inspbill`。**贴源层 ≠ 业务表**,启用冷链不会让页面出数。且冷链最长 1 小时,达不到人员作业要求(见 `00-总体方案` §4.2)。因此走已跑通的热关注回读(与 §3.3 同一机制)。
+
+### 5.3 改动点(待确认后执行)
+
+| 文件 | 改动 |
+|------|------|
+| `DataPlatform/HotWatch/MdpHotWatchService.cs` | `PUR_ORDER` 关注表清单增加 `qms_qcp_inspecapplyn`、`qms_qcp_insappnentry`、`qms_qcp_inspbill`;新增 `ApplyIqcAsync` 把三表按 §5.4 映射 UPSERT 进本库业务表 |
+| 同上 | `ShouldTerminateAsync` 的 `PUR_ORDER` 终止条件补一条:报检分录 `FINSPECTSTATUS='检验完成'` 且箱码全部离开待检 |
+| `Web/src/views/aidop/s5/iqc/iqcResultList.vue` | 接真实接口(当前是空骨架,不发请求) |
+| `Web/src/views/aidop/s5/iqc/iqcTaskList.vue` | 删除已过期的「只读」注释(第 122–125 行),实际已接流程抽屉 |
+| `mdp_entity.S5_IQC_INSPBILL_SQLSERVER` | 修正 `biz_key_expr`(现为不存在的 `Domain,BillNbr`,应为 `FBILLNO` 或 `id`);是否启用另议 |
+
+### 5.4 回读映射
+
+三表两库列同构,映射规则统一为「同名列 1:1 + 补 `tenant_id`」:
+
+| 165 源表 | 本库目标表 | 业务键 | 补列 |
+|---------|-----------|-------|------|
+| `qms_qcp_inspecapplyn` | 同名 | `FBILLNO` | `tenant_id` |
+| `qms_qcp_insappnentry` | 同名 | `glid` + `FSRCORDERNUM` + `FMATERIALCFG` + `FLOTNUMBER`(`id` 为 165 侧生成,不可直接复制,理由同 `RecID`) | `tenant_id` |
+| `qms_qcp_inspbill` | 同名 | `FBILLNO`(回退 `lydjbh`+`hid`) | `tenant_id` |
+
+租户解析沿用 `ResolveLocalPurOrdTenantAsync`(由 `FSRCORDERNUM` → `PurOrdRctDetail` → `PurOrdDetail.tenant_id`)。
+
+### 5.4.1 回读字段归属(2026-08-11 补)
+
+「同名列 1:1」**不适用于** Ai-DOP 在本库独占写入的列。`qms_qcp_insappnentry` 的 `jyfzr`(认领人)、`yxj`(本库优先级)**不得**被 165 回读覆盖;`FINSPECTSTATUS` 只许前进;`jykssj`/`jywcsj` 空值不覆盖。完整矩阵与修复见 [`S5-IQC热回读覆盖本地自有字段修复执行任务书.md`](./S5-IQC热回读覆盖本地自有字段修复执行任务书.md) §4(后端 `1.0.334`)。
+
+### 5.5 验收
+
+**P1 的验收不依赖任何判定动作**(旧 DOP 已停运,无法造「判定合格」这个前置状态)。用现存的活样本直接验:
+
+1. 本例报检单 `RQLLJYSQ202608110001` 已存在于 165 且 `FINSPECTSTATUS='未检验'` → 登记热关注后 **10 秒内** Ai-DOP「来料检验申请列表」出现该单,状态「未检验」,`FMATERIALCFG=81HC0744`、`FAPPLYQTY=500`、`FSRCORDERNUM=RC202608110001` 与 165 逐列一致
+2. 本库 `MissedPrint.Status` 与 165 保持一致(当前应为 `I`)
+3. 165 侧字段被外部改动后(可用一次**只改本库不改 165** 的反向核对,或等 P3 判定后一并验证)能在 10 秒内跟上;`qms_qcp_inspbill` 在 P2 建单后回读到本库
+4. 全程 165 上无任何新增表/触发器/作业,无整行覆盖
+
+> 「判定完成后状态同步为『检验完成』」这条**移到 P3/P4 验收**,因为在 P3 交付前无人能产生该状态。
+
+---
+
+## 6. 第二层:Ai-DOP 主导判定 + 字段级回写 165(方案 B 主体)
+
+在 §5 回读底座之上追加三段。**Q1 已选 B,本层确定执行。**
+
+### 6.1 目标链路
+
+```
+165 自动报检(WMS 收货触发)
+  → 【P1】回读报检单/分录 → 本库
+  → 【P2】本库认领 + 生成检验单(编号走 165 NbrControl 的 IQC 段:前缀 IQC/1160/YYYYMMDD)
+  → 【已有】Ai-DOP 判定:检验员提交结果 ──【P3】此刻即回推 165(Q5)
+  → 【已有】主管通过(仅 Ai-DOP 内部流程,不再碰 165)
+  → 【WMS APP】上架移库(扫箱码 + 货架 1001/01-01-01)完成入库
+  → 【已有】收货/上架结果回读(§3 已跑通)→ 合格入库数出数
+```
+
+**全链路状态机(165 侧关键列的逐步取值,P4 逐行核对;斜体为本方案交付后才会发生的步骤)**:
+
+| 步 | 动作 | 执行方 | `MissedPrint.Status` | `PurOrdDetail` | 报检分录 `FINSPECTSTATUS` | 库存位置 |
+|---|---|---|---|---|---|---|
+| 1 | 生成标签 | Ai-DOP | `U` | — | — | — |
+| 2 | 扫码收货 | WMS APP | `I` | `RctQty=500`、`ReceiptQty=0` | `未检验`(自动报检) | 待检库 1000 |
+| 3 | 回读 | Ai-DOP | 同步 `I` | 同步 | 同步 | — |
+| 4 | *认领 + 生成检验单* | *Ai-DOP(P2)* | `I` | — | *`检验中`* | 待检库 1000 |
+| 5 | *提交结果(判合格)* | *Ai-DOP(P3)* | *`I`→`N`* | — | *`检验完成`* | **仍在 1000** |
+| 6 | 上架移库 | WMS APP | `N`→(APP 置位) | — | `检验完成` | **1000→1001 合格库** |
+| 7 | 回读 | Ai-DOP | 同步 | — | — | 合格入库数(`pcrksl`)出数 |
+
+**第 5 步不移库、不产生 `InvTransHist`**(§2.3 已定论)。合格入库数在第 7 步才出数,判合格后立刻查会是 0,属正常。
+
+> **Q5 已决(2026-08-11):写回时机 = 检验员提交结果即推 165**,与旧系统 `submitAfter` 挂点一致(§2.2)。主管通过/退回只走 Ai-DOP 内部审批流,不再触发 165 写入。
+>
+> 理由:① 与旧系统时序一致,WMS 侧行为可预期;② 待检库的货不会因为主管未及时审批而卡住;③ 现有 `IqcInspBillFlowService` 的 `submit-result` 已是天然挂点,`supervisor-approve` 里的 `TryEnqueueIqcPassOutboxAsync` 调用需**移到** `submit-result`,不是新增。
+>
+> **副作用须知**:提交即推意味着主管退回时,165 侧箱码已解禁。退回不做 165 侧回滚(旧系统同样不回滚),如需纠正走「不合格处理」重新判定。此项列入 §9 未解问题 U7。
+
+### 6.2 P2 检验单生成的决策(Q2)
+
+165 上本例检验单为 0 行,说明旧 DOP 的派工是人工 Web 动作。两条路:
+
+| 选项 | 做法 | 代价 |
+|------|------|------|
+| **B-2a** | Ai-DOP 自建检验单(本库 `qms_qcp_inspbill` 插行,`lydjbh=报检单FBILLNO`、`hid=报检分录id`),合格后连检验单一起推 165 | 需实现取号与派工 UI;165 上出现「由 Ai-DOP 建的检验单」,需确认旧系统是否接受 |
+| **B-2b** | 派工仍在旧 DOP,Ai-DOP 只回读检验单后做判定 | 用户仍要切系统一次,未彻底打通 |
+
+> **Q2 已决(2026-08-11):选 B-2a**,派工落在 Ai-DOP,检验单在本库自建后随合格闭环推送 165。
+>
+> **取号方案已于 2026-08-11 静态取证更正(原写「走 165 `NbrControl.IQC` 段、与 WMS APP 共用计数器」,依据错误,作废)。**
+>
+> **实测证据**:旧 DOP 的单号由低代码平台的 `rf_serialnumber` 表生成,与 165 的 `NbrControl` 是两套互不相干的机制:
+>
+> | `rf_serialnumber.id` | 用途 | `format` | `numbersize` | `numbertype` | `lasttime` |
+> |---|---|---|---|---|---|
+> | 396082363535429 | 来料检验**申请**单 | `RQLLJYSQ{DateTime<yyyyMMdd>}{number}` | 4 | 0 | **2026-08-11 13:09:37** |
+> | 396082502635589 | 来料**检验单** | `IQC{DateTime<yyyyMMdd>}{number}` | **4** | 3 | 2025-07-15 |
+>
+> 第一行的 `lasttime` 与本例报检单 `RQLLJYSQ202608110001` 的 `FAPPLYTIME`(`2026-08-11 13:09:37`)**精确一致**,且格式与实际单号逐字吻合 → 铁证。由此连带解决原「序号 3 位还是 4 位」的疑问:**4 位,按日重置**。
+>
+> **Q7 已决(2026-08-11):检验单号在 Ai-DOP 本库自建序列,格式复刻 `IQC` + `yyyyMMdd` + 4 位(如 `IQC202608110001`),不碰 165 的任何计数器。**
+>
+> 理由:① `rf_serialnumber` 是低代码平台的配置表,写它属于改 165 数据,破 `00B` §7.1;② 旧 DOP 停运后该表不再有人取号;③ 165 的 `NbrControl.IQC`(`RecID=20`、`NextValue=1`)从未被使用过,且 WMS APP 无 IQC 判定入口(§2.1 实测 `type=1` 菜单无该项)→ **撞号风险为零**,无需跨库共用计数器;④ 本库自建序列可直接用现成的租户内原子分配,不引入跨库事务。
+>
+> ⚠️ 与 P2 卡片里「走 `NbrSequenceService` 取 165 计数器」的旧描述冲突时,**以本条为准**。`numbertype` 的枚举语义(0/1/3/4)平台未文档化,不影响实现——本方案只需「按日重置、4 位」这一确定行为。
+
+### 6.3 P3 回写字段共管矩阵(回写白名单)
+
+遵守红线「回写只允许字段级 UPDATE」。**基线取自 §2.2/§2.3/§2.5 的静态取证,已定本**(原「最终以 P0 实测差异为准」的悬空条款作废:旧 DOP 停运,不存在实测差异这一步)。
+
+| 165 表 | 允许写列 | 值(合格分支,即 §2.2 分支 1) | 幂等键 |
+|-------|---------|--------------|-------|
+| `MissedPrint` | **仅** `Status`、`QtyAccN`、`QtyRejectN`、`UpdateUser`、`UpdateTime` | `Status='N'`、`QtyAccN=Qty`、`QtyRejectN=0` | `Domain+BarCode` |
+| `MissedPrintTransHist` | 整行 INSERT | `TransType='来料检验-通过'`、`Remark='来料检验单:'+QCNbr` | `Domain+BarCode+TransType+CreateTime` |
+| `qms_qcp_insappnentry` | `FINSPECTSTATUS`、`jywcsj`(+ 可选 `FINSPECTORID`、`jyfzr`) | `FINSPECTSTATUS='检验完成'`、`jywcsj=now(格式 120)` | `id`(165 侧) |
+| `qms_qcp_inspbill` | 整行 INSERT(仅 B-2a) | 本库检验单同构推送,`id` 由 165 生成不可复制 | `FBILLNO` |
+
+**箱码状态与数量的赋值规则(`pr_WMS_SaveIQCResult` 原文逻辑,P3 照此实现;无循环、无按箱分摊,比预想简单)**:
+
+```sql
+-- 合格(@QCStatus='YES',即 §2.2 分支 1):所有箱码统一置 N
+QtyAccN = Qty, QtyRejectN = 0, Status = 'N'
+
+-- 其余(@QCStatus='NO'):
+QtyAccN    = case when @MRBStatus=3 or IsAccN=1 then Qty else 0 end
+QtyRejectN = case when @MRBStatus in (0,2)      then Qty else 0 end
+Status     = case when @MRBStatus=3 or IsAccN=1                then 'N'
+                  when @MRBStatus in (0,2) or @PartRctFlag=2   then 'R'
+                  else Status end        -- 保持原状态
+```
+
+| 分支(§2.2 编号) | `@MRBStatus` | 箱码 `Status` 结果 | `QtyAccN` | 结果描述 `@ResultDescr` |
+|---|---|---|---|---|
+| 1 合格 | 0 | 全部 `N` | `Qty` | `通过` |
+| 2 合格但挑选 | 1 | 被挑中(`IsAccN=1`)→`N`,其余保持原状态 | 挑中箱=`Qty` | `MRB挑选使用` |
+| 3 不合格退货 | 2 | 全部 `R` | 0 | `MRB退货` |
+| 4 不合格让步接收 | 3 | 全部 `N` | `Qty` | `MRB让步接收` |
+| 5 不合格挑选 | 1 | 同分支 2 | 同上 | `MRB挑选使用` |
+
+`IsAccN` 仅在挑选场景(`@PartRctFlag=2`,需传 `@Barcodes` 箱码清单)置 1;本方案 `@Barcodes=''`,故分支 2/5 在不传箱码时**不会解禁任何箱码**。
+
+**合格分支的精确最小写入集**(其余列在 `@IsRctTemp=0` 时全部保持原值,勿多写):`Status`:`I`→`N`;`QtyAccN`:`0`→`500`;`QtyRejectN`:`0`;`UpdateUser`/`UpdateTime`。**`InvStatus`、`PurOrd`、`OrdNbr`、`Remark` 均不变**——它们的 `case` 分支都以 `@IsRctTemp>0` 或 `Status='J'` 为条件。
+
+> **`Status='J'`(待点数复核)分支不会触发**:其开关 `GeneralizedCodeMaster`(`FldName='SystemConfig'`、`Val='IsPurOrdRctReconfirmQty'`、判据 `Comments='1'`)在 165 上**未配置该行**,取默认 0。原 P0 第 ⑹ 项据此关闭。
+
+**禁止写(逐条附理由,执行者不得自行放宽)**:
+
+| 表 / 列 | 为什么不写 |
+|---|---|
+| `PurOrdRctDetail.QCNbr` / `StatusByQC` / `QCDescr` / `Qc` / `QcDate` | **旧系统在本例路径(`ReceiptQty=0` → `@IsRctTemp=0`)下本就不写这些列**(§2.3 已定论)。补写等于新增旧 DOP 没有的行为,超出 `00B` §7.4 的例外范围。原矩阵含此行,已删除 |
+| `PurOrdRctDetail.RctQty` / `QtyReturn` | 同上,属 `temp` 分支产物 |
+| `InvTransHist`、`LocationDetail`、`PurOrdRctMaster` | 收货/上架过账产物,由 165 的 `pr_WMS_BPM_RctPurOrdAndPurOrdByBarcode` 与「上架移库」负责;复刻过账等于双写库存 |
+| `PurOrdDetail.ReceiptQty` | **它就是 `@IsRctTemp` 的取值源**(§2.3)。改它会把 165 的收货路径从甲切到乙,行为剧变 —— 最高危禁写项 |
+
+**合格入库数(`pcrksl`)仍从回读的 `InvTransHist` 推导,不由 Ai-DOP 生成**;出数时点见 §6.1 状态机第 7 步。
+
+### 6.4 P3 的执行器改造(Q3)
+
+现有 `TryEnqueueIqcPassOutboxAsync` 打向 `QMS_API` 占位源。两条路:
+
+| 选项 | 做法 | 评价 |
+|------|------|------|
+| **B-3a** | 改为 `target_source_code='DOPDEMORQ_SQLSERVER'`,按 §6.3 矩阵下发多表字段级 UPDATE/INSERT(沿用本轮已跑通的 `MdpDbPushExecutor` + resolve 子查询) | 与红线一致(零存储过程、C# 复刻、字段级),复用已验证通道 |
+| **B-3b** | 推检验单行到 165 后,调用 165 现成的 `qms_WMS_SaveIQCResult @id` 让旧系统自己写 | 语义最保真,但需执行器新增「调存储过程」能力,且与红线 3「新系统零存储过程、用 C# 复刻」的取向相悖 |
+
+> **Q3 已决(2026-08-11):选 B-3a**,改打 `DOPDEMORQ_SQLSERVER`,按 §6.3 矩阵下发字段级 UPDATE / 行 INSERT,复用本轮已实测跑通的 `MdpDbPushExecutor`(含 resolve 子查询解外键)。执行器**不新增调存储过程能力**。
+>
+> **2026-08-11 复核确认 B-3a 可行(曾一度考虑改 B-3b,已否)**:`pr_WMS_SaveIQCResult` 全文 14442 字符、依赖 12 个对象、内部还调两个子过程,看似不可能用字段级复刻。但 §2.3 定论后可知,在本例路径(`@IsRctTemp=0`)下它的**净写入面只有两处**——`UPDATE MissedPrint` 与 `INSERT MissedPrintTransHist`(其余分支全部不命中)。这两处都是简单的字段级写入,`MdpDbPushExecutor` 现有能力即可覆盖。
+>
+> 因此:**无需为本包扩展执行器的存储过程调用能力,`00B` §7.2 保持不开例外**(§11.2 已据此收紧)。若日后要支持乙路径(暂收在检),才需重新评估。
+>
+> 保留 `QMS_API` 分支代码:真实 QMS 地址落地后可双投,不删除。
+
+### 6.5 上架不在本方案范围(Q4 已决:不做)
+
+旧 DOP 合格分支 `@Shelf=''`,上架是独立动作。**Q4 已决(2026-08-11):本次不搬上架**——本方案完成后,上架仍在 WMS APP「上架移库」做(扫箱码 + `1001/01-01-01`)。
+
+若日后要搬进 Ai-DOP,属另一份任务书,且需先补 165 的 `EmpWorkDutyMaster` up-shelf 职责数据(现 0 行,见 [`WP7 §3.5`](./旧DOP-MES-WMS/对接任务书/WP7-基础数据与任务推送.md) 派发卡片)。
+
+---
+
+## 7. 执行任务书(卡片)
+
+### P0 · 静态取证(✅ 已完成 2026-08-11,无需任何操作)
+
+> **原卡片「在旧 DOP 跑通一次真实检验并抓变动列」已作废**:旧 DOP 已停运,仅作移植参考,不得要求任何旧 DOP 上的操作。改用**静态取证**——直读 165 的低代码平台元数据(`rf_program`/`rf_flow`/`rf_serialnumber`)与存储过程定义反推旧系统行为。
+>
+> **静态取证比原计划更完整**:跑一次实例只能覆盖一条判定路径(合格),而静态取证拿到了全部五个分支的规格。**产出即 §2.2、§2.3、§2.5、§6.3,无独立交付物。P1–P3 可直接开工。**
+
+原卡片的六项「关键待验证」逐条结论:
+
+| # | 原待验证项 | 结论 | 依据 |
+|---|-----------|------|------|
+| ⑴ | `PurOrdRctDetail.QCNbr/StatusByQC` 是否被写 | **不写**。且 P3 **不得**补写(原卡片写「若未写则 P3 必须显式补写」,该指示作废——补写属新增旧系统没有的行为) | §2.3 门控表 + `@IsRctTemp` 归因 |
+| ⑵ | 箱码是否 `I→N` | **是**,合格分支全部箱码统一置 `N` | §6.3 赋值规则 |
+| ⑶ | 是否产生 `MobileTask`(上架任务) | **不产生**。`pr_WMS_BPM_AddMobileTask` 的门控是 `@IsRctTemp>0`,本例 `=0` | §2.3 |
+| ⑷ | 库存是否离开待检库 1000 | **不离开**。IQC 不过账,靠 WMS APP「上架移库」移到 1001 | §2.3 + §6.1 状态机 |
+| ⑸ | 检验单号格式(3 位还是 4 位、走哪个计数器) | **`IQC`+`yyyyMMdd`+`4` 位,按日重置**;走低代码 `rf_serialnumber`(id `396082502635589`),**不是** 165 `NbrControl` | §6.2 Q7(`lasttime` 与报检单 `FAPPLYTIME` 精确吻合) |
+| ⑹ | `IsPurOrdRctReconfirmQty` 取值(`Status='J'` 复核分支) | **未配置 → 默认 0,分支不触发**;且它仅在 `@IsRctTemp>0` 时生效,与本例双重无关 | §6.3 末尾注 |
+
+**原卡片「风险:判定不可逆,本例 500 是唯一活样本」现已消解**:静态取证不消耗样本。`RQLLJYSQ202608110001` / 500 件仍处于 `未检验`,**完整保留给 P4 端到端验收**,另两箱(`...0010002`、`...0010003`)仍可作备用样本。
+
+### P1 · 报检/检验单回读(§5 底座,必做)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 三表回读到本库业务表,S5 三个 IQC 页面出真实数据 |
+| 前置 | **无(P0 已完成)**。`FINSPECTSTATUS` 的完整流转取值已取证:`未检验` →(认领后仍为 `未检验`)→ `检验中`(「来料检验」按钮写)→ `检验完成`(写回过程自动写,或「检验完成」按钮手工兜底),见 §2.5 |
+| 改动 | 见 §5.3;映射见 §5.4 |
+| 验收 | 见 §5.5 |
+| 不做 | 不启用冷链实体;不改贴源层;不动 L0/L1 过账骨架 |
+
+### P2 · 本库检验单生成(Q2=B-2a,确定要做)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | 报检单 → 检验单的派工动作落在 Ai-DOP |
+| 前置 | P1(P0 第 ⑸ 项已由静态取证关闭) |
+| 改动 | ① **任务列表**(不是申请列表,见 §2.5.2)增「认领」「检验员调配」「优先级调整」「检验完成」四个操作 + 「来料检验」(生成检验单)入口,逐条对照 §2.5.3 / §2.5.4 复刻,含「非认领人不得操作」与「已有检验单不得重复生成」两道校验;② 单号按 **Q7**:本库自建序列,格式 `IQC`+`yyyyMMdd`+4 位,**不碰 165 任何计数器**;③ 写本库 `qms_qcp_inspbill`(`lydjbh=报检单FBILLNO`、`hid=报检分录id`、`FMATERIALCFG`、`FRINSQTY`、`jyr`、`FINSPESTARTDATE` 等) |
+| 验收 | 认领后 `jyfzr` 写入;非认领人点「来料检验」被拒;重复生成检验单被拒;生成后任务列表出现该检验单且流程抽屉可提交结果;单号形如 `IQC202608110001`,当日连号 |
+| 不做 | 不改 165 的 `rf_serialnumber` 与 `NbrControl`;不复刻低代码工作流引擎本身(Ai-DOP 用现有 `IqcInspBillFlowService` 审批流即可) |
+| 风险 | `jyfzr` 口径:旧系统混用「用户 id」与「用户名」(§2.5.3 注),本包**统一存用户 id**,需在 UI 显示时解析为姓名;序号 4 位单日上限 9999,超限行为需定义(旧系统未定义) |
+
+### P3 · 合格回推 165(Q3=B-3a,确定要做)
+
+| 项 | 内容 |
+|----|------|
+| 目标 | Ai-DOP 判合格后,165 侧箱码解除待检,可被 WMS APP 上架 |
+| 前置 | P1、P2(P0 已完成)。**另有一项硬前置**:165 侧 `MissedPrint.PurLine` 必须与 `PurOrdDetail.Line` 一致——`pr_WMS_SaveIQCResult` 用 `t.PurOrd=m.PurOrd AND t.PurLine=m.PurLine` 关联箱码,`PurLine` 错则箱码集为空、报「标签总数与收货数不一致」。该 bug 已于本轮修复(§11.4 坑 5),**推送前须抽查真实值不为 0** |
+| 挂点 | **检验员提交结果时(Q5)**:把 `TryEnqueueIqcPassOutboxAsync` 的调用从 `supervisor-approve` **移到** `submit-result`,不是新增调用点 |
+| 改动 | ① 改造 `TryEnqueueIqcPassOutboxAsync`:`target_source_code` 由 `QMS_API` 改为 `DOPDEMORQ_SQLSERVER`,payload 按 §6.3 矩阵多表字段级下发,箱码状态按 §6.3 赋值规则计算(保留 `QMS_API` 分支代码);② `MdpDbPushExecutor.AllowedTables` 增 `MissedPrintTransHist`、`qms_qcp_insappnentry`、`qms_qcp_inspbill`(**不增 `PurOrdRctDetail` 写列,不新增 `op=PROC` 能力**);③ 入队前按 §2.3 的入口校验清单自检(数量平衡、`QCNbr` 未占用、箱码总数一致),不通过则不入队并给出业务提示;④ **修订 `00-总体方案.md` §4.3 + 附录A + WP5 场景 2 第 5 步**(§4 强制配套动作,本卡交付物之一) |
+| 验收 | 提交合格后:165 `MissedPrint.Status` 由 `I`→`N`、`QtyAccN=500`、`QtyRejectN=0`;`MissedPrintTransHist` 新增一行 `TransType='来料检验-通过'`;`qms_qcp_insappnentry.FINSPECTSTATUS='检验完成'` 且 `jywcsj` 有值;`InvStatus`/`PurOrd`/`OrdNbr`/`Remark` **保持原值不变**;随后 WMS APP「上架移库」可正常扫码上架;重复触发不产生重复写(`idem_key` 生效) |
+| 不做 | 不写 `PurOrdRctDetail` 任何列(§6.3 禁止写,旧系统本路径下也不写);不写库存与过账表;执行器不新增调存储过程能力;不因主管退回而回滚 165 |
+| 风险 | 双写覆盖。回写须带乐观校验(如 `WHERE IsNull(Status,'')='I'`)确认箱码未被 WMS 先改过;**注意不能再用 `StatusByQC` 做判据**(该列本路径下恒为空) |
+
+### P4 · 端到端验收
+
+| 项 | 内容 |
+|----|------|
+| 场景 | **全程只用 Ai-DOP + WMS APP,不涉及旧 DOP**:① 用现存活样本 `RQLLJYSQ202608110001`(500 件,仍为 `未检验`)直接从第 4 步接入;② 或新开一箱(`...0010002`)从生成标签走全程 |
+| 步骤 | Ai-DOP 认领 → 生成检验单 → 提交结果(判合格)→ 查 165 箱码解禁 → WMS APP 上架移库 → Ai-DOP 看到合格入库数 |
+| 核对 | 逐步对照 §6.1 状态机的 7 行;**第 5 步后合格入库数应仍为 0**(正常,非缺陷),第 7 步才出数 |
+| 时延 | 各段回读 P95 ≤ 10 秒(沿用 WP8 口径) |
+| 留证 | `doc/plan/UAT留证/` 步骤记录 + 关键表前后值(基线见 §7.5) |
+| 通过判据 | 最终判据是**「WMS APP 上架移库能扫成功」**——旧 DOP 已停运,无法用「与旧系统行为一致」做判据,改用下游可继续作业作为等价验收 |
+
+### 7.5 基线快照(2026-08-11 已采集,`LotSerial=260811013`)
+
+> 原为 P0 的 diff 基线。P0 改静态取证后,**本快照的用途转为 P3/P4 的验收对照基线**——样本未被消耗,下表各值至今仍然有效,P3 判定后逐列比对即可。
+
+| 对象 | 检验前值(= 当前值) |
+|------|---------|
+| `PurOrdDetail` | `RctQty=500`、`ReceiptQty=0`、`QtyReturned=0` |
+| `PurOrdRctDetail` | `QCNbr=''`、`StatusByQC=''`、`QcQty=0`、`Location=1000` |
+| `MissedPrint` | `Status='I'`、`RctNbr=RC202608110001`、`Location=1000`、`Shelf=01-01-01` |
+| `LocationDetail` | 1 行(`RecID=56676`)、`Location=1000`、`QtyOnHand=500` |
+| `InvTransHist` | 1 行(`rct-po-ins` 采购收货) |
+| `MissedPrintTransHist` | 1 行 |
+| `WMSInvTransHist` / `MissedPrintTraceRecord` / `PurOrdRctHist` / `PurOrdDetailBatch` | 0 行 |
+| 报检分录 | `FINSPECTSTATUS='未检验'`、`FJOINQTY=NULL` |
+| 检验单 / 检验结果 | 0 行 / 0 行 |
+
+---
+
+## 8. 决策清单
+
+| 编号 | 决策 | 影响 | 状态 |
+|------|------|------|------|
+| **Q1** | 方案 A(165 权威、只读)还是方案 B(Ai-DOP 判定 + 回写) | 决定 P2/P3 是否开工;选 B 须修订 `00-总体方案` §4.3 | ✅ **已定 = B**(2026-08-11) |
+| **Q2** | 检验单由 Ai-DOP 自建(B-2a)还是回读 165(B-2b) | 决定 P2 是否存在 | ✅ **已定 = B-2a**(2026-08-11) |
+| **Q3** | 回推走 DB 字段级 UPDATE(B-3a)还是调 165 存储过程(B-3b) | 决定执行器是否新增调 SP 能力 | ✅ **已定 = B-3a**(2026-08-11);**已被** [`S5-IQC合格上架过账(QcCheck)`](./S5-IQC合格上架过账(QcCheck)执行任务书.md) **Q9 部分推翻**:甲路径上架过账须受限 `op=PROC`,其余字段级回写仍守 B-3a |
+| **Q4** | 是否同期把「上架」也搬进 Ai-DOP | 需先补 `EmpWorkDutyMaster` up-shelf 数据 | ✅ **已定 = 本次不做**(2026-08-11);**已被 QcCheck 任务书 Q8 推翻**:APP「上架移库」对质检库存不可用,改由 Ai-DOP 调 `SaveInvUpShelf @TransCode=QcCheck` |
+| **Q5** | 回写 165 的时机:检验员提交即推,还是主管审核通过才推 | 决定 P3 挂点;影响待检库放行时效 | ✅ **已定 = 提交即推**(2026-08-11),与旧系统 `submitAfter` 一致,见 §6.1 |
+| **Q6** | 任务列表的认领/调配/优先级/检验完成是否一并复刻 | 决定 P2 范围;「认领」是「来料检验」按钮的前置硬校验 | ✅ **已定 = 全部复刻**(2026-08-11),规格见 §2.5.3 |
+| **Q7** | 检验单号走 165 计数器还是本库自建序列 | 决定 P2 取号实现;原定「与 WMS APP 共用 `NbrControl`」依据错误 | ✅ **已定 = 本库自建,复刻 `IQC`+`yyyyMMdd`+4 位**(2026-08-11),见 §6.2 |
+
+> 决策已闭合,**P0 已完成(静态取证)**;P1–P3 可直接按卡片派发,无需等待任何前置取证。
+
+## 9. 未解问题
+
+| # | 问题 | 处置 |
+|---|------|------|
+| ~~U1~~ | ~~`pr_WMS_SaveIQCResultByRctNbr` 已加密,实际写入未知~~ | ✅ **已关闭**:该过程属乙路径(APP「检验确认/检验后部分收货」自用),本方案既不调用也不复刻,其语义不影响交付(§2.3) |
+| ~~U2~~ | ~~`RctType='rc'` 与 `temp` 的口径冲突根因~~ | ✅ **已关闭**:门控实为 `PurOrdDetail.ReceiptQty`(非 `RctType`),本例 `=0` 故走甲路径;`IsPurOrdRctReconfirmQty` 未配置(§2.3、§6.3) |
+| U3 | 报检单 `FQUALITYORG`/`FINSPEDEPTID` 为空,任务列表是否需按组织过滤 | 旧 DOP 已停运无法实操验证;查 `rf_program.sqlstring`(program `396052925665349`)可静态确认其 WHERE 是否含组织条件 —— **P2 开工时顺带查,不阻断** |
+| U4 | `mdp_entity.S5_IQC_INSPBILL_SQLSERVER.biz_key_expr='Domain,BillNbr'` 与表结构不符 | 既有缺陷,P1 顺手修正,勿启用后再改 |
+| U5 | `1000/01-01-01` 不在 `LocationShelfMaster` 中,但箱码与报检分录都记着该货架 | 待检货架是否需登记,属基础数据议题,记录不阻断 |
+| U6 | 环境遗留:旧后端实例 `54820` 无法结束,仍在抢 `mdp_outbox` | 联调期需注意消息被旧实例消费;已知项 |
+| **U7** | Q5「提交即推」下,主管退回时 165 侧箱码已解禁,本方案不回滚 | 旧系统同样不回滚(`submitAfter` 一旦执行无补偿动作)。纠正走「不合格处理」重新判定。**若业务不接受,需另设计补偿动作**,属范围外 |
+| **U8** | 165 存在配置项 `SystemConfig / IsPurOrdRctTemp`(「WMS是否走采购暂收」,`IsActive=1`) | **风险提示**:若有人启用采购暂收,`PurOrdDetail.ReceiptQty` 将 >0 → `@IsRctTemp=1` → IQC 的写入面剧变(触发收货过账与上架任务),本方案的字段级复刻会与之偏离。**变更该配置前必须重新评估 P3**,建议纳入 165 配置变更评审清单 |
+| **U9** | 旧系统 `jyfzr` 混用「用户 id」(认领按钮)与「用户名」(校验比较)两种口径 | 本包统一存用户 id(P2 卡片已定)。此偏离为**有意为之**,非缺陷 |
+
+## 10. 红线自检(交付前逐条确认,与 §11.8 合并执行)
+
+- [ ] 165 上无新增表、触发器、作业,未改任何存储过程与表结构
+- [ ] 回写全部为字段级 UPDATE / 明确的行 INSERT,无整行覆盖
+- [ ] 新系统未新增存储过程
+- [ ] 未新增「业务页面直读 165」旁路
+- [ ] 热链查询走业务键 `IN` 窄查询,未对 `MissedPrint`/`InvTransHist` 做时间戳全表扫
+- [ ] `00-总体方案.md` §4.3 与 `00B` §7.4 的例外修订已完成(Q1=B 的强制配套,见 §11.2)
+
+---
+
+## 11. 执行者输入契约(派发时必须一并交付)
+
+> 本节是为「把某张卡片直接交给另一个人或模型执行」准备的。缺本节,执行者会按 `00B` 的原文自检并判定本方案违规。
+
+### 11.1 交付包清单
+
+派发任一卡片时,必须同时给出:
+
+1. 本文档(全文,不可只给单张卡片)
+2. [`00B-执行须知(模型输入契约)`](./旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md)——连接、代码落点、产出规范、自检、禁止事项
+3. [`00-总体方案.md`](./旧DOP-MES-WMS/对接任务书/00-总体方案.md) §1 硬约束、§4.2 时效模型、§4.3 数据归属
+4. ~~P0 的变动列清单~~ → **已不需要**:P0 改为静态取证并已完成,其产出就在本文档内(§2.2 判定矩阵、§2.3 写入门控、§2.5 行为规格、§6.3 赋值规则)。**P1/P2/P3 无外部前置,可直接开工。**
+
+**执行者硬性须知(本包最容易踩的前提错误)**:
+
+- **旧 DOP 已停运,仅作移植参考。任务描述、验收步骤、复现指引中都不得出现「在旧 DOP 上操作」**。若发现某项验证只能靠旧 DOP 完成,说明该项设计有问题,应改为静态取证或改用「WMS APP 能否继续作业」作为等价判据。
+- **旧 DOP 的 IQC 不在旧仓 C# 源码里**(`ZZYDOP_OLD` 全仓零命中),去那里找实现是白费功夫;实现在 165 的低代码元数据中,定位路径见 §2.5.1。
+- 本文档内凡标注「已定论 / 已关闭 / 已作废」的条款,**其被推翻的原文一并保留**(含删除线或「原文写…依据错误」字样),目的是防止执行者从旧版本或摘要中拿到已作废的结论。**遇到冲突一律以标注日期最新的条款为准。**
+
+### 11.2 本包对既有禁令的例外(Q1=B 的必然结果)
+
+| 禁令 | 本包处置 |
+|------|---------|
+| `00B` §7.4「**不新增旧 DOP 没有的自动回写闭环**」 | **开例外**:Ai-DOP 判定合格 → 自动回写 165 属本包核心。例外范围**严格限于 §6.3 白名单列**,其余场景该禁令继续生效。修订 `00B` §7.4 加注例外指向本文档,属 P3 交付物 |
+| `00-总体方案` §4.3「执行域不回写执行字段」 | **开例外**:仅「检验」一项,范围同上(见 §4) |
+| `00B` §7.1「不改 165 任何结构」 | **不开例外**。含 `NbrControl` 既有行也不改——P2 只按现有 `IQC` 段取号,不改配置 |
+| `00B` §7.2「不在新系统里调用或新建存储过程」 | **不开例外**。Q3 定 B-3a 而非 B-3b 正是为守这一条;2026-08-11 复核确认可守——`pr_WMS_SaveIQCResult` 在本例路径下净写入面只有两处,字段级足够覆盖(§6.4)。**执行器不得新增 `op=PROC` 之类的存储过程调用能力** |
+| `00B` §7.3「回写不得整行覆盖」 | **不开例外**。§6.3 全部为字段级 UPDATE 或明确的行 INSERT,且必须带业务键 |
+| `00B` §7.5「不开业务页面直读 165 旁路」 | **不违反**。P1 是「165 → 回读 → 本库业务表 → 页面读本库」,页面不直连 165。S5 三个 IQC 页面读本库业务表 `qms_qcp_*` 是既有实现,本包不改其数据源方向 |
+
+### 11.3 实现范本锚点(照抄现成实现,勿另发明)
+
+本轮(后端 1.0.330 / 1.0.331)已落地的采购单推送与收货回读,是本包每一段的现成范本:
+
+| 卡片 / 环节 | 范本位置 | 说明 |
+|-----------|---------|------|
+| P1 热关注登记 | `DataPlatform/Wms/PurOrdWmsPushService.cs` → `EnqueueBarcodesAsync` 末尾的 `EnrollPurOrderAsync` 调用点 | 登记失败只 `LogWarning`、不阻断主流程的写法照抄 |
+| P1 关注表清单 | `DataPlatform/HotWatch/MdpHotWatchService.cs` → `EnrollPurOrderAsync` | 现为 `PurOrdMaster`/`PurOrdDetail`/`MissedPrint`,本包在此追加 qms 三表 |
+| P1 回读落库 | 同上 → `ApplyPurchaseReceiptAsync`、`ApplyShipmentStatusAsync` | **新增的 `ApplyIqcAsync` 照这两个方法的结构写**:参数签名、`tenantId` 解析、按业务键 UPDATE、逐表 try 隔离 |
+| P1 租户解析 | 同上 → `ResolveLocalPurOrdTenantAsync` | 由 `FSRCORDERNUM` → `PurOrdRctDetail` → `PurOrdDetail.tenant_id` 的等价写法 |
+| P1 关注终止 | 同上 → `ShouldTerminateAsync` 的 `PUR_ORDER` 分支 | 追加「报检分录 `FINSPECTSTATUS='检验完成'`」条件,注意保留 `total>0` 的守卫(标签未推达时不得终结) |
+| P2 取号 | `NbrSequenceService`(WP2 产出) | 与 WMS APP 共用 165 计数器,原子分配 |
+| P3 出站入队 | `PurOrdWmsPushService.EnqueueShipmentChainAsync` | `idem_key` 构造、多表分条入队、`MdpOutboxWakeSignal` 唤醒的写法 |
+| P3 执行器 | `DataPlatform/Executors/MdpDbPushExecutor.cs` → `AllowedTables`、`ApplyResolvesAsync`、`ResolveSpec` | 白名单扩表与受限 resolve 子查询解外键(`RecID` 类外键不可直接复制) |
+
+### 11.4 已知坑(本轮实测踩过,必读)
+
+| # | 坑 | 症状 | 处置 |
+|---|----|------|------|
+| 1 | **Ai-DOP 自建单的 `Domain` 为 `NULL`** | 回读 UPDATE 影响 0 行,静默无效 | `WHERE` 一律写 `IFNULL(Domain,'') IN ('', @Domain)`,不要写 `Domain=@Domain` |
+| 2 | **MySQL collation 冲突** | 更新 `scm_shdzb` 时报 `Illegal mix of collations`(`CAST(d.id AS CHAR)=z.glid` 这类 JOIN) | 先单独查出父表 `id`,再作为字符串参数分开执行 UPDATE,避免跨表 JOIN 比较 |
+| 3 | **165 侧 `id`/`RecID` 是 IDENTITY** | `不能为表 'xxx' 中的标识列插入显式值` | 推送一律用自然键(如 `scm_shdshph` 用 `(shdh, xh)`);`qms_qcp_*` 的 `id` 同理,映射见 §5.4 |
+| 4 | **Ai-DOP 的 `MissedPrint` 表没有 `tenant_id` 列** | `Unknown column 'tenant_id' in 'where clause'`,HTTP 500 | 查该表不要加租户过滤;租户从关联的采购单侧解析 |
+| 5 | **SQL 别名与 C# DTO 属性名不一致会静默取到默认值** | `MissedPrint.PurLine` 被写成 0 而不报错(`po_billline` vs `PoBillLine`) | 用 SqlSugar 手写 SQL 映射 DTO 时,别名必须与属性名逐字对应;落库后必须抽查关键列真实值 |
+| 6 | **改 `csproj` 要保持 UTF-8** | 曾因双重编码(UTF-8 被当 GBK 再存)损坏 `<Description>` 标签,导致 MSBuild XML 解析失败、`dotnet run` 起不来 | 只用编辑工具改,不用 shell 重定向;改完立刻 `dotnet build` 验证 |
+| 7 | **环境遗留:旧后端实例 `54820` 仍在抢 `mdp_outbox`** | 出站消息被无法访问的旧进程消费,回写看似丢失 | 联调期确认只有一个后端实例在跑;必要时用 `force-repoll` 手动触发 |
+| 8 | **门控归因陷阱:`@IsRctTemp` 取自 `PurOrdDetail.ReceiptQty`,不是 `PurOrdRctDetail.RctType`** | 按 `RctType` 推断写入面,得出「需补写 `PurOrdRctDetail.QCNbr`」等错误结论(本任务书 §2.3 原文即如此,已更正) | 判断 165 存储过程分支是否命中时,务必读变量赋值原文,不要按变量名或注释推断。`MRBStatus` 的注释同样与实际调用不一致(§2.2 注) |
+| 9 | **`MissedPrint.PurLine` 是 IQC 回写的隐性命门** | `pr_WMS_SaveIQCResult` 用 `PurOrd+PurLine` 关联箱码;`PurLine=0` 时箱码集为空 → 报「标签总数与收货数不一致」,但错误信息完全不指向 `PurLine` | 坑 5 的下游影响。推送标签后必须抽查 165 侧 `MissedPrint.PurLine` 真实值与 `PurOrdDetail.Line` 一致 |
+| 10 | **MCP SQL 通道会拦截含写操作关键字的语句** | 即使是 `SELECT CHARINDEX('upd'+'ate ...')` 这类只读查询,只要出现 `update` 字面量就被拒(`SQL contains denied statement`) | 取证时用字符串拼接绕开(`'upd'+'ate'`)。另:`sys.dm_sql_referenced_entities` 可快速拿到某过程的读写对象清单,比逐段读定义高效 |
+
+### 11.5 DDL 判定(各卡片是否需要 UpdateScript)
+
+| 卡片 | 是否需要 `UpdateScripts/<版本号>.sql` |
+|------|-----------------------------------|
+| P1 | **不需要**。本库 `qms_qcp_inspecapplyn`/`insappnentry`/`inspbill` 三表已存在且与 165 列同构(本库多 `tenant_id`);`ado_mdp_hot_watch` 已存在 |
+| P2 | **不需要建表**(写既有 `qms_qcp_inspbill`)。若最终决定另建派工记录表,才写新脚本 |
+| P3 | **不需要**。`mdp_outbox` 已存在;执行器白名单是代码常量,非 DDL |
+| 通用 | 若确需新脚本:取**大于 `1.0.331`** 的版本号,必须可重复执行(`IF NOT EXISTS` 保护),不得复用或修改已有脚本。当前最新已编号脚本为 `1.0.328.sql`;目录下另有未编号的 `WIP-S5-IQC-INVPOSTING.sql`(L0/L1 过账骨架用),**勿复用、勿修改** |
+
+### 11.6 版本号现值(`00B` §5.2 的记载已过期)
+
+| 项 | `00B` §5.2 记载 | **实际现值(HEAD `b58e50dfd`)** |
+|----|----------------|------------------------------|
+| 后端 `Admin.NET.Web.Entry.csproj` | `1.0.299` | **`1.0.331`**(三处 `<Version>`/`<AssemblyVersion>`/`<FileVersion>` 同号) |
+| 前端 `Web/package.json` | `2.4.275` | **`2.4.283`** |
+
+升号规则不变:只跟随本次提交实际纳入的对应端改动 patch +1;纯文档提交不升号。
+
+### 11.7 造数与打标
+
+本例活样本锚点(P0/P4 验证都用它):
+
+| 项 | 值 |
+|----|----|
+| 采购单 | `PO202608110003` 行 1,物料 `81HC0744`,供应商 `10003232` |
+| 送货单 | `10003232-20260811-0001` |
+| 收货单 | `RC202608110001` |
+| 批号 | `260811013` |
+| 已收箱码 | `10003232-20260811-00010010001`(500,`Status='I'`) |
+| 未收箱码 | `...0010002`(500)、`...0010003`(67) |
+| 报检单 | `RQLLJYSQ202608110001` |
+
+新造数据一律带可识别前缀(沿用 `2608` 批号段),并在 [`doc/plan/UAT留证/`](./UAT留证/) 留步骤记录。**不得在 165 上留无法识别的测试数据。**
+
+### 11.8 自检清单(`00B` §6 + 本包附加)
+
+提交前逐条过:
+
+- [ ] 后端可编译:`dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj`
+- [ ] 全仓无存储过程调用:`rg -n "EXEC\s+pr_|CALL\s+pr_" server/ Web/` 应无命中(守 `00B` §7.2 / Q3=B-3a)
+- [ ] 无明文密码入库:`rg -n "dopsa" server/ --glob "!*.md"` 人工确认
+- [ ] UpdateScript 版本号未与现有冲突(见 §11.5)
+- [ ] 版本号已按实际提交端 patch +1(见 §11.6)
+- [ ] 回写语句全部带业务键,`keys` 为空时抛异常,无整行覆盖
+- [ ] 回写带乐观校验(箱码用 `WHERE IsNull(Status,'')='I'`;**不得用 `StatusByQC`**,该列在本路径下恒空,见 P3 卡片风险栏)
+- [ ] 重复触发同一合格闭环不产生重复写(`idem_key` 生效,实测一次重放)
+- [ ] §11.4 的 **10** 个坑逐条确认未复现
+- [ ] 交付物中**没有任何要求在旧 DOP 上操作**的步骤(§11.1 硬性须知)
+- [ ] 未写 `PurOrdRctDetail` 任何列、未写 `PurOrdDetail.ReceiptQty`(§6.3 禁止写清单)
+- [ ] 未新增执行器的存储过程调用能力(§11.2 / §6.4)
+- [ ] 检验单号形如 `IQC202608110001`,且未读写 165 的 `rf_serialnumber` / `NbrControl`(Q7)
+- [ ] 推送前已抽查 165 侧 `MissedPrint.PurLine` 与 `PurOrdDetail.Line` 一致(§11.4 坑 9)
+- [ ] §10 红线自检全部通过
+- [ ] 本卡片「验收」栏逐条自评并留证

+ 345 - 0
doc/plan/UAT多租户数据方案/README.md

@@ -0,0 +1,345 @@
+# UAT 全功能测试与 Demo 演示 · 多租户数据方案
+
+> **状态**:方案待确认(本轮只出方案与逐表清单,**未写任何 SQL、未改任何代码**)
+> **建立时间**:2026-08-11
+> **目标库**:MySQL `aidopdev`(`123.60.180.165:3306`)
+> **配套文档**:[逐表清单.md](./逐表清单.md)
+> **上游依赖**:[租户隔离彻底修正执行任务书](../租户隔离彻底修正执行任务书.md)、[租户隔离全量审计报告-20260810](../租户隔离全量审计报告-20260810.md)
+> **复用**:[S1-S4全局UAT场景测试数据生成方案](../S1-S4全局UAT场景测试数据生成方案.md)(A 套主线沿用该方案,不重写)
+
+---
+
+## 0. 前置阻断:租户隔离未修完之前,不要开始造数
+
+这一节是本方案里唯一的**硬性顺序约束**,必须先读。
+
+`租户隔离彻底修正执行任务书` 的 4 个批次**尚未全部落地**(该任务书首行仍标注「状态:待执行」,实际已部分落地但文档未更新)。后果是贴源层与 DWD 层仍在把 `tenant_id` 写成 `0`,按登录租户查询恒为空。
+
+**2026-08-11 实测污染快照**:
+
+| 表 | `tenant_id=0` | 正确租户 `797403760988229` | 影响页面 |
+|---|---|---|---|
+| `dwd_material_shortage` | **146(全部)** | 0 | S3 缺料看板恒空 |
+| `mdp_std_s6_report` | **2(全部)** | 0 | S6 报工标准层恒空 |
+| `mdp_std_production_issue` | **2(全部)** | 0 | S5 生产领料标准层恒空 |
+| `mdp_std_purchase_order` | 101 | 434 | S3/S4 采购口径偏低 |
+| `mdp_std_purchase_receipt` | 3 | 11 | S4 收货口径偏低 |
+
+对照 2026-08-10 审计报告,`dwd_material_shortage` 从 142 行涨到 146 行且**仍是 100% tenant=0**,说明写入侧仍在持续产生污染,不是存量遗留。
+
+**结论**:现在造的任何数据,只要经过 MDP 贴源/转换链路,都会重新掉进 `tenant_id=0`,演示时看板照样空白。**造数必须排在隔离修正之后**,否则做完还要返工。
+
+另有一处独立的脏数据:`ado_s8_exception` 共 42 行,`tenant_id` 全部为 `1`(既不是任何真实租户,也不是框架默认租户 `1300000000001`),导致 **S8 异常列表在所有租户下都是空的**。该项属隔离任务书批次 4 范围,本方案不重复处理,只在验收清单里登记。
+
+---
+
+## 1. 三套租户定位
+
+| 套 | 租户 ID | 组织 | 管理员账号 | 定位 | 与 165 的关系 |
+|---|---|---|---|---|---|
+| **A** | `797403760988229` | AIDOP | `AIDopAdmin` / `AIDOPDemo` | **真机联调**:MES/WMS APP 全链路 UAT | 唯一映射租户,`Stock:Source=Mes` |
+| **B** | `797403760988230` | AIDOP-Demo | `AIDOPDemoTenant` | **纯演示**:对外 Demo、投标演示、培训 | 不连,`Stock:Source=Local` |
+| **C** | `824585161322565` | AIDOPB | `AIDopAdminB` | **新客户样板**:从零建模操作演练 + 隔离参照 | 不连,`Stock:Source=Local` |
+
+三套**全部复用现有租户,不新建**。理由:新建租户需要重跑菜单授权、角色基线、KPI 配置克隆,而这三个租户的框架侧资产已经就位,改造成本远低于新建。
+
+### 1.1 三套各自要回答的问题
+
+- **A 套**:系统接上真实 165 之后,业务能不能跑通?——面向内部 UAT 与验收。
+- **B 套**:系统讲不讲得出一个完整的制造运营故事?——面向客户演示,要求任何时候打开都好看、不依赖 165 可用性。
+- **C 套**:一个新客户拿到系统,从建主数据到出计划要怎么走?——面向实施交付演练与售前。
+
+---
+
+## 2. 现状基线(2026-08-11 实测)
+
+### 2.1 全库租户总览
+
+| 租户 ID | 组织 | 菜单数 | 用户 | 角色 | 组织数 | 处置 |
+|---|---|---|---|---|---|---|
+| `1300000000001` | 系统默认 | 476 | 9 | 27 | 7 | 平台超管,**不作业务演示** |
+| `1300000000777` | Ai-DOP 演示公司 | 473 | 1 | 1 | 1 | 空壳,本轮不动 |
+| `1300000000888` | Demo默认组织 | 468 | 1 | 8 | 1 | 空壳,本轮不动 |
+| `788012541595717` | test | 274 | 1 | 8 | 1 | 测试残留,**建议停用**(见 §9) |
+| `797403760988229` | AIDOP | 473 | 2 | 9 | 1 | **A 套** |
+| `797403760988230` | AIDOP-Demo | **273** | 1 | 7 | 5 | **B 套**(菜单缺 200 条,须补) |
+| `824585161322565` | AIDOPB | 475 | 2 | 9 | 1 | **C 套** |
+
+### 2.2 三套业务数据量对比
+
+`—` 表示 0 行。完整逐表见 [逐表清单.md](./逐表清单.md)。
+
+| 层 | 代表表 | A `…229` | B `…230` | C `…565` |
+|---|---|---|---|---|
+| S0 物料 | `ItemMaster` | 16150 | — | 5958 |
+| S0 客户 | `CustMaster` | 1267 | — | 57 |
+| S0 供应商 | `SuppMaster` | 3185 | — | 374 |
+| S0 BOM | `ProductStructureMaster` | 57199 | — | 3824 |
+| S0 工艺路线 | `RoutingOpDetail` | 32309 | — | 4824 |
+| S0 标准工序 | `StdOpMaster` | 598 | — | — |
+| S0 产线 | `LineMaster` | 109 | — | **—** |
+| S0 产线明细 | `ProdLineDetail` | 5817 | — | **4823** |
+| S1 销售订单 | `crm_seorder` | 19 | — | — |
+| S1 订单明细 | `crm_seorderentry` | 26 | — | — |
+| S1/S2 工单 | `WorkOrdMaster` | 22 | — | — |
+| S2 工序 | `WorkOrdRouting` | 269 | — | — |
+| S2 日计划 | `PeriodSequenceDet` | 1296 | — | — |
+| S2 工作日历 | `ShopCalendarWorkCtr` | 574 | — | 56 |
+| S2 节假日 | `HolidayMaster` | **—** | — | 11 |
+| S3 物料需求 | `ic_demandschedule` | 1189 | — | — |
+| S3 采购订单 | `PurOrdMaster` | 46 | — | — |
+| S5 库存 | `InvMaster` | 16022 | — | 2346 |
+| S5 IQC | `qms_qcp_inspbill` | 5 | — | 1 |
+| S6 设备 | `EquipmentList` | **—** | — | **165** |
+| S6 模工具 | `MoldToolList` | **—** | — | **290** |
+| S6 IPQC | `qms_gcjyd` | **—** | — | 1 |
+| S7 FQC | `qms_fqcbj` | **—** | — | — |
+| S8 订单档案 | `ado_s8_order_flow_order` | **—** | **20** | — |
+| S9 KPI 值 | `ado_s9_kpi_value_l1_day` | 2319 | — | — |
+| S9 KPI 主数据 | `ado_smart_ops_kpi_master` | 169 | **—** | 169 |
+
+### 2.3 从基线读出的三个结论
+
+**第一,没有任何一个租户能演完整个系统。** A 套有完整的 S1–S5 主线但 S6/S7/S8 全空;C 套只有 S6 台账;B 套只有 S8 的 20 单故事线。演示时在租户之间切换是不可接受的。
+
+**第二,B 套几乎是白纸。** 除了 20 单 S8 订单档案,业务表全部为 0,连 KPI 主数据(169 条)和 200 条菜单都缺。B 套的工作量最大,但也最可控,因为不用考虑存量数据兼容。
+
+**第三,C 套存在既有数据不一致。** `ProdLineDetail` 有 4823 行产线明细,而 `LineMaster` 产线主表是 0 行——明细挂在不存在的产线上。这会让 S0 产线维护页和 S2 排程在 C 套下行为异常,须先修数据再谈演示。
+
+---
+
+## 3. 决策定案
+
+以下为本方案的定案项。若需推翻,请在评审时逐条指出,不要在执行阶段临时改。
+
+| # | 决策项 | 定案 | 理由 |
+|---|---|---|---|
+| D1 | 租户套数 | **3 套,全部复用现有租户** | 框架侧资产已就位,新建成本更高 |
+| D2 | B 套主数据规模 | **精简子集**:约 300 物料 / 40 BOM / 40 工艺路线 / 8 产线 | 全量复制会让库体积翻倍;精简子集页面加载更快,演示更流畅 |
+| D3 | B 套主数据来源 | 从 A 套 `…229` **按演示剧本涉及的成品倒推裁剪**,改写 `tenant_id` | 真实数据的字段分布比造假数据可信 |
+| D4 | B 套是否要可实操 | **要**。BOM、工艺、产线日历、产能必须自洽,能现场点通「下单→评审→下达→排程→齐套」 | 现场能跑通的说服力远高于静态截图 |
+| D5 | 演示主线 | **沿用 S8 现有 20 单订单档案**(`SO-2026-001` 为主角),S1/S2/S3 数据围绕这 20 单构造 | 让 S8 档案页能真正下钻到其他模块,而不是孤立存在 |
+| D6 | 日期策略 | **相对当天生成**:脚本内用 `CURDATE()` 偏移,覆盖「过去 30 天 + 未来 30 天」 | 固定日期的演示数据几个月后全变历史,看板曲线会塌 |
+| D7 | 落地方式 | **主数据与租户基线走版本化 `UpdateScripts`;业务单据走 `doc/plan/sql/` 可重置手工脚本** | 主数据只需一次且要跟随环境重建;业务单据要能反复清空重灌 |
+| D8 | C 套形态 | **保留现有主数据,只补齐一致性,不造业务单据** | 空白单据本身就是 C 套的价值(见 §7 隔离验证口径) |
+| D9 | A 套改造范围 | **只补缺口,不重造**:S6 台账、S6 IPQC、S7 FQC/成品入库、S8 异常与订单档案、S2 节假日 | A 套 S1–S5 主线已由既有 UAT 方案建立,重造会破坏已有验收基线 |
+| D10 | 造数与隔离修正的顺序 | **隔离修正批次 1–3 完成并清理存量后,才开始造数** | 见 §0,否则数据会重新掉进 `tenant_id=0` |
+| D11 | A 套补 `HolidayMaster` | **直接补 11 行,不重排日计划** | S2 排程根本不读该表,补数据不影响排程基线,见 §3.2 |
+| D12 | C 套 `LineMaster` 缺失 | **从 `ProdLineDetail` 反查补建 13 行** | 成本仅 13 行,可保住 4684 行有效产能数据,见 §3.3 |
+| D13 | B 套客户名与单号 | **全部脱敏,用构造的演示名称与单号** | 对外演示不得泄露 A 套真实客户信息 |
+
+### 3.1 D2 的量化口径
+
+B 套精简子集按「成品倒推」裁剪,具体规则:
+
+1. 选定 20 个成品料号(对应 S8 的 20 单)。
+2. 沿 `ProductStructureMaster` 逐层展开 BOM,收集全部子件料号,预计 250–300 个。
+3. 沿 `RoutingOpDetail` 收集这些料号用到的工艺路线与标准工序。
+4. 收集这些工艺路线引用到的产线、工作中心。
+5. 只导入上述闭包内的主数据,闭包外一律不导。
+
+这样保证「任何一个演示页面上点开的物料,都能查到它的 BOM、工艺和产线」,不会出现点进去空白的尴尬。
+
+### 3.2 D11 的依据:补节假日不会动排程
+
+初稿曾判断「A 套补 `HolidayMaster` 会改变排程基线、可能需要重排 1296 行日计划」。**该判断经代码核查后不成立**,此处更正。
+
+`HolidayMaster` 在全仓只有一个业务消费方——S1 订单评审的交期计算:
+
+```244:273:server/Plugins/Admin.NET.Plugin.AiDOP/Order/CapacityDateCalculator.cs
+    private static DateTime GetNextWorkDay(
+        DateTime startTime,
+        List<CalendarRow> curCalendars,
+        List<HolidayRow> holidays)
+    {
+        int weekDay = (int)startTime.DayOfWeek;
+        // 周六、周日:检查是否有「调班」(周末上班)
+        // 工作日:检查是否是「休假」(节假日)
+        var isHoliday = holidays.Any(p =>
+            p.Dated.Date == startTime.Date &&
+            p.Ufld1.Equals("休假", StringComparison.OrdinalIgnoreCase));
+        if (isHoliday)
+        {
+            return GetNextWorkDay(startTime.AddDays(1), curCalendars, holidays);
+        }
+        return startTime;
+    }
+```
+
+而 S2 排程 `ProductionScheduleGenerationService` **完全不引用该表**,它只读 `ShopCalendarWorkCtr` 的 `WeekDay + IsWorkDay`,即「按星期几的周模式」判断工作日。
+
+因此补 11 行节假日的影响面是:
+
+| 影响对象 | 结果 |
+|---|---|
+| S2 排程结果、`PeriodSequenceDet` 1296 行日计划 | **不受影响**,不触发重排,不向 165 回写 |
+| 存量订单已落库的交期字段 | **不受影响**,不会回算 |
+| 今后新评审订单的交期 | 会比现在**晚几天**,因为开始跳过春节国庆等假期 |
+
+最后一项是**修正而非回归**:当前 `HolidayMaster` 为空,等于把所有法定节假日都当成工作日在算产能,交期系统性偏乐观。
+
+### 3.2.1 顺带登记:S2 排程不识别节假日
+
+由上可知,S2 排程只按星期几的周模式排产,**没有节假日概念**——它会把春节、国庆整周排成工作日。这是一个真实的功能缺陷,会让跨节假日的工单计划完工日普遍偏早。
+
+本方案**只登记不修复**:它属于排程算法范畴,应并入 `S2排程算法正确性修复执行任务书` 的后续批次统一处理,不在数据准备的范围内顺手改。
+
+### 3.3 D12 的依据:补 13 行即可救回 4684 行产能
+
+C 套 `ProdLineDetail` 有 4823 行明细却没有一行 `LineMaster`。实测该批明细只涉及 **13 个不同产线码、7 个 Site**,其中 **4684 行 `Rate > 0`**(产能数据有效)。
+
+两个方案的成本对比:
+
+| 方案 | 工作量 | 后果 |
+|---|---|---|
+| **反查补建 `LineMaster`**(定案) | 插入 **13 行** | 保住 4684 行产能数据,C 套可演示排程 |
+| 清掉孤儿明细 | 删除 4823 行 | C 套产能归零,排程演示不了 |
+
+补建时需为 `LineMaster` 的非空列取值:`company_ref_id`、`factory_ref_id`、`Domain`、`Line`、`IsActive`。前两者对齐 C 套现有主数据的取值,`LineType` / `Workshop` 等可空列按 `Site` 推断填写。
+
+---
+
+## 4. 落地方式与脚本编号
+
+### 4.1 两条通道的分工
+
+| 通道 | 放置位置 | 执行时机 | 适用内容 | 可否重复执行 |
+|---|---|---|---|---|
+| **版本化迁移** | `server/Admin.NET.Web.Entry/UpdateScripts/{x.y.z}.sql` | 后端启动时自动 | 租户菜单授权、KPI 基线、B/C 套主数据、数据一致性修复 | 幂等,但**执行过的脚本内容不得再改** |
+| **手工重置脚本** | `doc/plan/sql/demo/` | 人工按需 | B 套业务单据(订单/工单/排程/采购/质检/异常) | 设计为「先清后灌」,可反复 |
+
+### 4.2 迁移脚本硬约束
+
+沿用 `AutoVersionUpdate` 的既有约束,违反会导致后端启动阻断:
+
+- 文件必须是 **LF 行尾**(CRLF 会导致解析失败)。
+- 脚本号必须 **≤ `Admin.NET.Web.Entry.csproj` 的 `<Version>`**,否则不会被执行。
+- 不得使用 `DELIMITER`。
+- 已执行过的脚本**内容一个字都不能改**(改了不会重跑,还破坏可追溯性)。
+- 每个 `x.y.z.sql` 配一个 `x.y.z.verify.sql` 做落库校验。
+- 需在 `.csproj` 中补 `None Update` 项,确保脚本被复制到输出目录。
+
+### 4.3 脚本号分配
+
+当前后端版本 `1.0.327`,已使用到 `1.0.325`。可用号:
+
+| 脚本号 | 内容 | 备注 |
+|---|---|---|
+| `1.0.326` | B 套租户基线补齐:菜单 273→473、KPI 主数据 169 条、角色对齐 | 幂等 |
+| `1.0.327` | C 套数据一致性修复:补 `LineMaster`,对齐 `ProdLineDetail` 孤儿行 | 幂等 |
+| `1.0.328+` | B 套精简主数据导入 | **需先把 `.csproj` 三处版本号 patch 到对应号** |
+
+B 套主数据体量较大,建议按域拆成多个脚本(物料 / BOM / 工艺 / 产线与日历 / 仓储与质量基础),而不是塞进一个巨型文件,便于单点重跑与排错。
+
+### 4.4 日期相对化写法
+
+所有带业务日期的插入统一用当天偏移,示例口径:
+
+```sql
+-- 订单日期:过去 30 天内随机分布
+DATE_SUB(CURDATE(), INTERVAL FLOOR(RAND() * 30) DAY)
+-- 交期:未来 10 到 45 天
+DATE_ADD(CURDATE(), INTERVAL 10 + FLOOR(RAND() * 35) DAY)
+-- 日计划:以本周一为基准向后铺
+DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL n DAY)
+```
+
+随机分布须**用固定种子或预先算好的偏移表**,保证同一脚本重复执行结果一致,否则每次重灌看板数字都在变,无法做验收比对。
+
+---
+
+## 5. 执行阶段划分
+
+| 阶段 | 内容 | 前置 | 产物 |
+|---|---|---|---|
+| **P0** | 隔离收口 WP-1/2/3 落地 + 存量零值清理 | 无 | 见[收口任务书](../租户隔离收口与造数前置任务书.md) |
+| **P1** | B 套租户基线:菜单、KPI 主数据、角色 | P0 | `1.0.329.sql` |
+| **P2** | C 套数据一致性修复 | P0 | `1.0.330.sql` |
+| **P3** | B 套精简主数据导入 | P1 | `1.0.331+` 系列 |
+| **P4** | B 套业务单据造数(S1→S2→S3→S5→S6→S7→S8 顺链路) | P3 | `doc/plan/sql/demo/` |
+| **P5** | A 套缺口补齐(S6 台账 / S6 IPQC / S7 FQC / S8 异常 / S2 节假日) | P0 | 手工脚本 |
+| **P6** | 三套 MDP / KPI **全局刷新一次**与看板验收 | P4、P5 | 验收报告 |
+
+P1、P2、P5 相互独立,可并行。P3 必须在 P1 之后,P4 必须在 P3 之后。
+
+脚本号从 `1.0.329` 起,是为给收口任务书让位:它占用 `1.0.326`(IPQC 建表默认租户)与 `1.0.328`(存量清理)。
+
+### P6 的硬约束:DWD / KPI 刷新必须全局跑,不得按租户分别调用
+
+S3/S4 的 DWD 构建(`S4MdpSyncTransformService.BuildDwdAsync`、S3 的 `dwd_supplier_risk` 构建)**不接受租户参数**,是「一次重建全部租户」的全局转换:按 `stat_date` 删掉当日所有租户的行,再从 `dwd_supplier_delivery` 把所有租户重建回来。
+
+因此若按租户 A 调 `RunInboundAsync(tenantId: A)`,贴源拉取限定在 A,但随后的 DWD 构建仍会重建**所有**租户的当日数据——此时 B、C 的上游若尚未就位,它们的当日 DWD 会按不完整的上游被重建。
+
+**正确做法**:三套租户的贴源与标准层全部就位后,**全局跑一次** DWD/KPI 构建,再逐租户验收看板。这不是代码缺陷,是该转换的设计粒度,详见收口任务书 §0.2 / §0.4。
+
+---
+
+## 6. 跨模块影响
+
+按仓库协作约定,以下影响须在执行前获得确认。
+
+**补 B 套菜单(273→473)** 会写 `SysTenantMenu`,影响该租户所有用户的侧栏结构与权限可见范围。新增的 200 条菜单里包含 S0 建模、S5 仓储、S9 数据中台等管理类入口,需确认演示账号是否都该看到。
+
+**给 B 套造 `WorkOrdMaster` / `WorkOrdRouting` / `PeriodSequenceDet`** 会同时改变 S1 计划联动看板、S2 工单执行进度与排程看板、S3 齐套与缺料看板、S9 KPI 聚合的口径——这几个模块共用同一批工单事实表。造数后必须整体回归,不能只看 S2。
+
+**修 C 套 `LineMaster` 缺失**(补 13 行)会让 S0 产线维护页首次出现数据,并改变 S2 排程在该租户下的行为——当前因产线主表为空,排程大概率走空转分支。属预期改善,但需在 C 套单独回归一次排程。
+
+**A 套补 `HolidayMaster`**(补 11 行)**不影响 S2 排程**,只影响 S1 今后新评审订单的交期计算,且是把系统性偏乐观的交期修正回来。依据与影响面见 §3.2。**不触发重排,不向 165 回写。**
+
+**A 套补 S8 异常数据** 会让 S8 异常列表、S8 大屏、通知规则在该租户下第一次出现内容,连带影响其统计口径与告警触发。
+
+---
+
+## 7. 165 约束下无法演示的路径
+
+只有 A 套 `797403760988229` 在 `AiDOP:Stock:MesTenantId` 中与 165 一一对应。以下能力**在 B、C 两套下无法演示**,且受「不改 165」硬约束,无法通过造数绕过:
+
+| 能力 | 依赖 | B/C 套表现 |
+|---|---|---|
+| MES APP 投产、报工 | 165 `PeriodSequenceDet` / `LineStatusDet` | 无法演示 |
+| WMS APP 发料、收料、扫码 | 165 `InvMaster` 实时库存与领料单 | 无法演示 |
+| 下达工单时创建领料单 | `CreatePickBillService` → 165 `NbrMaster` | 无法演示 |
+| 执行实绩热同步回读 | `MdpHotWatchService` 读 165 | 无数据 |
+| 工序人员追溯 | `OpTransEmployee`(尚无入站同步) | 全租户均空 |
+
+**演示话术建议**:B 套定位为「运营决策与协同层」,MES/WMS 现场执行层的演示统一切到 A 套进行,或改用录屏。不要在 B 套现场点这些入口。
+
+---
+
+## 8. 验收口径
+
+每套独立验收,判据如下。
+
+**通用**:
+- 该租户下 S0–S9 所有叶子菜单页面打开**不报错**,且除 §7 列出的路径外**不为空**。
+- 任意列表页随机抽 3 行下钻,关联对象都能查到(物料能查到 BOM、工单能查到订单、采购单能查到需求)。
+- 用其他租户账号登录,**查不到本套任何一行业务单据**。
+
+**A 套专项**:MES APP 能取到当天日计划并投产;WMS APP 能按领料单发料;`mdp_outbox` 无新增死信。
+
+**B 套专项**:S8 订单档案 20 单每一单都能下钻到 S1 订单、S2 工单、S3 采购;看板日期窗口落在「最近 30 天」内;断开 165 连接后所有页面行为不变。
+
+**C 套专项**:业务单据类页面全部为空且**不报错**;能完整走通「新建物料→建 BOM→建工艺→建产线→建日历→下订单→评审→下达→排程」;`MoldToolList` 等已知泄露项在 A/B 套登录时**看不到 C 套的行**(见 §8.1)。
+
+### 8.1 隔离验证的口径限制
+
+C 套自身有主数据(5958 物料、165 设备、290 模工具),所以它**只能检出业务单据层的泄露**,检不出主数据层的泄露。已知的主数据层泄露单独登记,不依赖 C 套发现:
+
+```12:14:server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/ToolingLedgerService.cs
+/// sql.txt 无 WHERE,本服务不按 tenant_id/Domain 过滤(与旧系统全表查询一致)。排序 id DESC。
+/// 本服务仅 SELECT:无新增/编辑/删除/写入;MoldToolList 有 tenant_id 但不据其过滤(透传参数)。
+```
+
+即 C 套的 290 副模工具目前在任何租户登录时都可见。该项属隔离任务书范围,本方案只登记不修复。
+
+---
+
+## 9. 本轮明确不做
+
+- **不修租户隔离缺陷**。全部归 `租户隔离彻底修正执行任务书`,本方案只做前置依赖登记。
+- **不修 `ado_s8_exception` 的 `tenant_id=1`**。同上,归隔离任务书批次 4。
+- **不新建租户**,不动 `1300000000777`、`1300000000888`。
+- **不删除 `788012541595717`(test 租户)**。建议将其 `Status` 置为停用,但删除涉及关联数据清理,单独评估。
+- **不改任何业务代码**。本方案纯数据,如执行中发现必须改代码才能造出合理数据,须停下来单独提案。
+- **不动 165**。任何需要写 165 才能演示的能力,一律走 §7 的话术处理。
+- **不做数据回滚脚本**。B 套业务单据脚本自带「先清后灌」,A/C 套改动前做库备份即可。

+ 247 - 0
doc/plan/UAT多租户数据方案/逐表清单.md

@@ -0,0 +1,247 @@
+# UAT 多租户数据方案 · 逐表清单
+
+> 配套主方案:[README.md](./README.md)
+> 现状列为 **2026-08-11 实测**(MySQL `aidopdev`),目标列为本方案建议值。
+> 本清单只描述「造什么、造多少、从哪来」,**不含 SQL**。
+
+---
+
+## 图例
+
+| 记号 | 含义 |
+|---|---|
+| **A** | 真机联调租户 `797403760988229`(AIDOP) |
+| **B** | 纯演示租户 `797403760988230`(AIDOP-Demo) |
+| **C** | 新客户样板租户 `824585161322565`(AIDOPB) |
+| `—` | 当前 0 行 |
+| **保持** | 不改动现状 |
+| **裁剪** | 从 A 套按演示剧本闭包裁剪后改写 `tenant_id` 导入 |
+| **构造** | 按剧本新造,无对应存量来源 |
+| **修复** | 现状存在但不一致,需订正 |
+
+通道列:`MIG` = 版本化 `UpdateScripts`;`SQL` = `doc/plan/sql/demo/` 手工可重置脚本。
+
+---
+
+## 0. 租户框架资产
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `SysTenantMenu` | 租户菜单授权 | 473 | 保持 | **273** | **473** | 475 | 保持 | MIG |
+| `SysRole` | 角色 | 9 | 保持 | 7 | **9** | 9 | 保持 | MIG |
+| `SysOrg` | 组织 | 1 | 保持 | 5 | 保持 | 1 | 保持 | — |
+| `SysUser` | 用户 | 2 | 保持 | 1 | **3**(演示用不同角色账号) | 2 | 保持 | MIG |
+| `ado_smart_ops_kpi_master` | KPI 主数据 | 169 | 保持 | **—** | **169** | 169 | 保持 | MIG |
+| `ado_smart_ops_kpi_calc_config` | KPI 计算配置 | 21 | 保持 | 21 | 保持 | 21 | 保持 | — |
+
+**B 套菜单缺口是最优先项**。当前 273 条相对 A 套的 473 条少 200 条,缺的主要是 S0 建模、S5 仓储、S9 数据中台三块,直接导致演示时侧栏残缺。
+
+**B 套建议加两个演示账号**,分别绑定计划员与车间主管角色,用于演示不同岗位看到的不同首页与待办。
+
+---
+
+## 1. S0 运营建模
+
+### 1.1 制造域
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `ItemMaster` | 物料主数据 | 16150 | 保持 | — | **~300**(裁剪) | 5958 | 保持 | MIG |
+| `ProductStructureMaster` | BOM 头 | 57199 | 保持 | — | **~40**(裁剪) | 3824 | 保持 | MIG |
+| `ProductStructureOp` | BOM 工序关联 | 808 | 保持 | — | **~120**(裁剪) | — | 保持 | MIG |
+| `RoutingOpDetail` | 工艺路线明细 | 32309 | 保持 | — | **~200**(裁剪) | 4824 | 保持 | MIG |
+| `StdOpMaster` | 标准工序 | 598 | 保持 | — | **~40**(裁剪) | **—** | **~40**(裁剪) | MIG |
+| `WorkCtrMaster` | 工作中心 | 4 | 保持 | — | **4**(裁剪) | 7 | 保持 | MIG |
+| `LineMaster` | 产线主表 | 109 | 保持 | — | **8**(裁剪) | **—** | **修复** | MIG |
+| `ProdLineDetail` | 产线明细/产能 | 5817 | 保持 | — | **~200**(裁剪) | 4823 | **修复** | MIG |
+
+**C 套的 `LineMaster` 为 0 而 `ProdLineDetail` 有 4823 行**,明细挂在不存在的产线上。定案为**反查补建**(决策 D12):实测这 4823 行只涉及 **13 个产线码、7 个 Site**,其中 4684 行 `Rate > 0`。也就是说插入 13 行 `LineMaster` 就能救回 4684 行有效产能数据,比清掉孤儿明细划算得多。补建时需为非空列 `company_ref_id` / `factory_ref_id` / `Domain` / `Line` / `IsActive` 取值,对齐 C 套现有主数据。
+
+**B 套 8 条产线**的选取要覆盖不同工艺类型(如机加、装配、焊接),让排程甘特图有层次感,不要全是同一类。
+
+### 1.2 销售与供应域
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `CustMaster` | 客户 | 1267 | 保持 | — | **10**(构造,用于 20 单剧本) | 57 | 保持 | MIG |
+| `SuppMaster` | 供应商 | 3185 | 保持 | — | **15**(裁剪) | 374 | 保持 | MIG |
+
+**B 套客户一律构造而非裁剪**(决策 D13),用可读的演示名称(如「华东精密机械」)替代真实客户名。脱敏范围不止客户名,以下都必须换成演示值:
+
+| 对象 | 处理 |
+|---|---|
+| 客户名称、简称、联系人、电话、地址 | 全部构造 |
+| 供应商名称与联系方式 | 全部构造(料号、规格可保留真实值) |
+| 销售订单号、合同号 | 用演示前缀(如 `DEMO-SO-2026-001`),不沿用 A 套真实单号 |
+| 采购订单号、发货单号、检验单号 | 同上,统一演示前缀 |
+| 物料料号与描述 | **可保留真实值**,制造属性本身不敏感,保真反而更可信 |
+
+单号统一加演示前缀还有个附带好处:一眼能看出某行数据属于演示套,误入 A 套时容易识别和清理。
+
+### 1.3 仓储与人员域
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `DepartmentMaster` | 部门 | 7 | 保持 | — | **8**(构造) | 210 | 保持 | MIG |
+| `EmployeeMaster` | 员工 | 464 | 保持 | — | **20**(构造) | 613 | 保持 | MIG |
+| `LocationMaster` | 库位 | 80 | 保持 | — | **12**(构造) | 34 | 保持 | MIG |
+| `LocationShelfMaster` | 货架 | **2** | **~20**(构造) | — | **24**(构造) | — | 保持 | MIG |
+
+**A 套货架只有 2 条**,而 S5 有货架维护与扫码相关页面,数据过薄。补到 20 条左右,覆盖多个库位下的多层货架。
+
+---
+
+## 2. S1 产销协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `crm_seorder` | 销售订单 | 19 | 保持 | — | **20**(构造,对齐 S8) | — | 保持空 | SQL |
+| `crm_seorderentry` | 订单明细 | 26 | 保持 | — | **~50**(构造) | — | 保持空 | SQL |
+| `ado_product_design` | 产品设计 | 2 | **5** | — | **6**(构造) | — | 保持空 | SQL |
+| `ado_contract_review` | 合同评审 | 5 | 保持 | — | **8**(构造,含驳回样例) | — | 保持空 | SQL |
+| `ASNBOLShipperMaster` | 发货通知 | **1** | **5** | — | **6**(构造) | — | 保持空 | SQL |
+| `ShippingPlan` | 发运计划 | **—** | **5** | — | **6**(构造) | — | 保持空 | SQL |
+| `WorkOrdMaster` | 工单 | 22 | 保持 | — | **30**(构造) | — | 保持空 | SQL |
+| `WorkOrdDetail` | 工单用料 | 658 | 保持 | — | **~400**(构造) | — | 保持空 | SQL |
+| `WorkOrdRouting` | 工单工序 | 269 | 保持 | — | **~300**(构造) | — | 保持空 | SQL |
+| `b_examine_result` | 齐套结论 | 409 | 保持 | — | **由齐套检查生成** | — | 保持空 | 运行 |
+
+**B 套的 20 单必须与 `ado_s8_order_flow_order` 现有 20 单的单号一一对应**,这是 D5 决策的落地点。若现有 S8 单号格式不适合直接复用为 `crm_seorder.order_no`,须先确认映射关系再造数。
+
+**`b_examine_result` 不手工造**,通过在 B 套实际触发一次齐套检查生成,这样结论与库存、BOM 自洽。
+
+**`ShippingPlan` 在 A 套为 0**,S1 发运计划页当前是空的,补 5 条。
+
+---
+
+## 3. S2 制造协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `ShopCalendarWorkCtr` | 工作日历 | 574 | 保持 | — | **~1500**(构造,8 产线全年) | 56 | **补全** | MIG |
+| `HolidayMaster` | 节假日 | **—** | **11** | — | **11** | 11 | 保持 | MIG |
+| `QualityLineWorkDetail` | 休息时段 | 138 | 保持 | — | **~40**(构造) | 6 | 保持 | MIG |
+| `PeriodSequenceDet` | 日计划 | 1296 | 保持 | — | **由排程生成** | — | 保持空 | 运行 |
+| `ScheduleResultOpMaster` | 排程结果 | 39 | 保持 | — | **由排程生成** | — | 保持空 | 运行 |
+| `ScheduleExceptionMaster` | 排产异常 | 928 | 保持 | — | **由排程生成** | — | 保持空 | 运行 |
+
+**B 套的日计划与排程结果一律不手工造**,而是把主数据和工单准备好之后,在 B 套实际跑一次全量排程生成。理由是排程结果与产能、日历、工序强耦合,手工造的数据经不起下钻,一点开工序明细就会露馅。
+
+**A 套补 `HolidayMaster` 无排程风险,直接补**(决策 D11)。S2 排程完全不读该表,只有 S1 的 `CapacityDateCalculator` 消费它来算交期。补 11 行不影响现有 1296 行日计划,不触发重排,不向 165 回写;只会让今后新评审订单的交期跳过法定假期,比现在晚几天——这是修正而非回归。依据见主方案 §3.2。
+
+**顺带登记**:S2 排程只按「星期几」的周模式判断工作日,**没有节假日概念**,会把春节国庆排成工作日。属排程算法缺陷,归 `S2排程算法正确性修复执行任务书` 后续批次,本方案不修。
+
+**C 套日历 56 行明显不足**(对照 A 套 574 行),若要演示排程需补全,与 `LineMaster` 修复一并处理。
+
+---
+
+## 4. S3 供应协同
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `ic_demandschedule` | 物料需求 | 1189 | 保持 | — | **~200**(构造) | — | 保持空 | SQL |
+| `srm_pr_main` | 采购申请 | 224 | 保持 | — | **20**(构造) | — | 保持空 | SQL |
+| `PurOrdMaster` | 采购订单头 | 46 | 保持 | — | **15**(构造) | — | 保持空 | SQL |
+| `PurOrdDetail` | 采购订单行 | 98 | 保持 | — | **~45**(构造) | — | 保持空 | SQL |
+| `srm_polist_ds` | 交货计划 | 161 | 保持 | — | **~45**(构造) | — | 保持空 | SQL |
+| `dwd_material_shortage` | 缺料事实 | **0(146 行在 tenant=0)** | **由 MDP 刷新生成** | — | **由 MDP 刷新生成** | — | 保持空 | 运行 |
+
+**`dwd_material_shortage` 是隔离缺陷的重灾区**,146 行全部落在 `tenant_id=0`。P0 阶段清理完毕后重跑 S3 MDP 刷新即可,不需要手工造。
+
+**B 套采购数据要覆盖三种状态**:正常按期到货、延期未到、部分到货。这是 S3 供应看板与 S4 欠料看板出图的前提。
+
+---
+
+## 5. S4 采购执行
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `scm_shd` | 供应商发货单头 | 5 | **12** | — | **10**(构造) | — | 保持空 | SQL |
+| `scm_shdzb` | 供应商发货单行 | 8 | **~35** | — | **~30**(构造) | — | 保持空 | SQL |
+| `PurOrdRctMaster` | 收货单头 | **4** | **10** | — | **8**(构造) | 1 | 保持 | SQL |
+| `PurOrdRctDetail` | 收货单行 | **4** | **~25** | — | **~20**(构造) | 1 | 保持 | SQL |
+
+**A 套 S4 数据量偏薄**(收货单只有 4 张 4 行),相对 46 张采购订单严重不成比例,看板统计几乎没有意义。建议补到 10 张左右。
+
+---
+
+## 6. S5 物料仓储
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `InvMaster` | 库存 | 16022 | 保持 | — | **~300**(裁剪,对齐物料闭包) | 2346 | 保持 | MIG |
+| `NbrMaster` | 单据头(收/发/退/领) | **9** | **30** | — | **25**(构造) | 2 | 保持 | SQL |
+| `NbrDetail` | 单据行 | 271 | 保持 | — | **~80**(构造) | 2 | 保持 | SQL |
+| `qms_qcp_inspecapplyn` | IQC 报检申请 | 5 | **12** | — | **10**(构造) | 1 | 保持 | SQL |
+| `qms_qcp_inspbill` | IQC 检验单 | 5 | **12** | — | **10**(构造,含不合格样例) | 1 | 保持 | SQL |
+
+**B 套库存量必须与 BOM 需求形成有意义的对比**:一部分物料充足(齐套通过)、一部分不足(齐套缺料)、一部分完全没有(采购在途)。这三档决定了齐套看板和缺料看板好不好看。
+
+**A 套 `NbrMaster` 只有 9 张单**,S5 的收料、发料、退料、领料四类单据页面几乎都是空的,需补到 30 张左右并覆盖四种类型。
+
+---
+
+## 7. S6 生产执行
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `EquipmentList` | 设备台账 | **—** | **30**(裁剪自 C 套) | — | **20**(裁剪) | 165 | 保持 | MIG |
+| `MoldToolList` | 模工具台账 | **—** | **40**(裁剪自 C 套) | — | **25**(裁剪) | 290 | 保持 | MIG |
+| `qms_gcjyd` | IPQC 检验单 | **—** | **10**(构造) | — | **10**(构造) | 1 | 保持 | SQL |
+
+**S6 是当前跨租户割裂最严重的模块**:设备与模工具全部堆在 C 套,A/B 两套一条都没有。C 套的 165 台设备和 290 副模工具是现成的真实数据,裁剪一部分改写 `tenant_id` 导入 A、B 两套即可,成本很低。
+
+**注意 `MoldToolList` 目前存在跨租户泄露**(`ToolingLedgerService` 无租户过滤),导入前 A/B 套用户其实已经能看到 C 套的 290 副模工具。导入后若泄露未修,会出现「自己的 40 副 + 别人的 290 副」混在一起,反而更糟。**因此 S6 台账导入必须排在隔离修复之后**。
+
+---
+
+## 8. S7 成品仓储
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `qms_fqcbj` | FQC 报检 | **—** | **8**(构造) | — | **8**(构造) | — | 保持空 | SQL |
+| `qms_qcpp_inspbill` | FQC 检验单 | **—** | **8**(构造) | — | **8**(构造) | — | 保持空 | SQL |
+| `NbrMaster`(WOI 类) | 生产入库单 | 含在 S5 计数内 | **10** | — | **8**(构造) | — | 保持空 | SQL |
+
+**S7 在所有租户下都是全空**,是三个模块里从零做起工作量最大的。FQC 数据须与 S2 完工工单挂钩,否则「成品入库」找不到来源工单。
+
+---
+
+## 9. S8 异常监控
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `ado_s8_order_flow_order` | 订单执行档案 | **—** | **10**(构造) | **20** | 保持 | — | 保持空 | SQL |
+| `ado_s8_exception` | 异常记录 | **0(42 行在 tenant=1)** | **~30** | — | **~30**(构造) | — | 保持空 | SQL |
+
+**`ado_s8_exception` 的 42 行全部落在 `tenant_id=1`**,是一个既不属于任何真实租户、也不是框架默认租户的野值。S8 异常列表因此在所有租户下都是空的。该项归隔离任务书批次 4,本方案不动,但它不修复的话 S8 演示就不完整。
+
+**B 套的 20 单档案已存在**,是三套里唯一现成的演示资产,D5 决策就是围绕它组织其余数据。
+
+**A 套建议也补 10 单档案**,让真机联调时能验证 S8 与真实业务数据的联动,而不只是看 B 套的静态剧本。
+
+---
+
+## 10. S9 运营指标
+
+| 表 | 说明 | A 现状 | A 目标 | B 现状 | B 目标 | C 现状 | C 目标 | 通道 |
+|---|---|---|---|---|---|---|---|---|
+| `ado_s9_kpi_value_l1_day` | KPI 日值 | 2319 | **由刷新生成** | — | **由刷新生成** | — | 保持空 | 运行 |
+| `mdp_std_*` / `dwd_*` | 标准层与事实层 | 部分 tenant=0 | **清理后重刷** | — | **由刷新生成** | — | 保持空 | 运行 |
+
+**S9 全部不手工造**,在 P4 阶段业务单据就位后,按模块跑 MDP 刷新作业生成。这也是检验前面造的业务数据是否自洽的最好方式——如果 KPI 跑出来是空的或数值离谱,说明上游数据有问题。
+
+---
+
+## 11. 工作量粗估
+
+| 阶段 | 内容 | 粗估 |
+|---|---|---|
+| P1 | B 套租户基线 | 0.5 天 |
+| P2 | C 套一致性修复 | 0.5 天 |
+| P3 | B 套精简主数据(裁剪闭包 + 导入脚本) | 2 天 |
+| P4 | B 套业务单据全链路造数 | 3–4 天 |
+| P5 | A 套缺口补齐(S5/S6/S7/S8) | 2 天 |
+| P6 | 三套刷新与验收 | 1 天 |
+
+合计约 **9–10 人天**,不含 P0 隔离修正本身。其中 P4 是最大不确定项,取决于 D5 的 20 单剧本与 S8 现有档案的字段对齐程度。

+ 1 - 0
doc/plan/UAT留证/2026-08-09-S1起真人测试/screenshots/.gitkeep

@@ -0,0 +1 @@
+

+ 53 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/00-基线快照.md

@@ -0,0 +1,53 @@
+# 基线快照(检验前)
+
+采集时间:2026-08-11 约 15:29(UTC+8),直连 `dopdemorq` 复核通过。
+
+> **用途已变更**:原为「旧 DOP 操作后逐列 diff」的基线。旧 DOP 停运、P0 改静态取证后,本快照转为 **P3/P4 验收的对照基线**。静态取证不动数据,**下表各值至今仍然有效**。
+
+## 锚点
+
+| 项 | 值 |
+|----|----|
+| Domain | `8010` |
+| 采购单 | `PO202608110003` 行 1,物料 `81HC0744` |
+| 收货单 | `RC202608110001` |
+| 送货单 | `10003232-20260811-0001` |
+| 批号 | `260811013` |
+| 已收箱码 | `10003232-20260811-00010010001`(500) |
+| 报检单 | `RQLLJYSQ202608110001` |
+
+## 9 表关键列
+
+| 对象 | 关键列 | 检验前值 |
+|------|--------|---------|
+| `PurOrdDetail` | `RctQty` / `ReceiptQty` / `QtyReturned` | `500` / `0` / `0` |
+| `PurOrdRctDetail` | `QCNbr` / `StatusByQC` / `RctQty` / `Location` / `RctType` | `''` / `''` / `500` / `1000` / `rc` |
+| `MissedPrint`(…0010001) | `Status` / `RctNbr` / `Location` / `Shelf` / `Qty` | `I` / `RC202608110001` / `1000` / `01-01-01` / `500` |
+| `LocationDetail` | 行数(`LotSerial=260811013`) | `1`(`Location=1000`) |
+| `InvTransHist` | 行数 | `1`(`rct-po-ins`) |
+| `MissedPrintTransHist` | 行数 | `1` |
+| `MobileTask` | 行数(TaskID∈收货单/报检单) | `0` |
+| `qms_qcp_insappnentry` | `FINSPECTSTATUS` / `FAPPLYQTY` / `FSRCORDERNUM` | `未检验` / `500` / `RC202608110001` |
+| `qms_qcp_inspbill` | 关联本报检单的行数 | `0` |
+
+## 配置
+
+| 项 | 值 |
+|----|----|
+| `IsPurOrdRctReconfirmQty`(`GeneralizedCodeMaster`,判据 `Comments='1'`) | **无此配置行** → 取默认 `0`,`Status='J'` 复核分支不触发 |
+| `IsPurOrdRctTemp`(`GeneralizedCodeMaster`,「WMS是否走采购暂收」) | **存在且 `IsActive=1`** —— 但本例 `PurOrdDetail.ReceiptQty=0`,实际走的是正式收货(甲路径)。⚠️ 若此开关生效导致暂收,IQC 写入面会剧变,见任务书 §9 U8 |
+| `NbrControl` IQC 段(165,WMS 侧) | `RecID=20` / `NbrType=IQC` / `NbrPre1=1160` / `NbrPre2=IQC` / `NextValue=1` / `MaxValue=999` —— **从未使用过;旧 DOP 不走这里** |
+| `rf_serialnumber`(低代码平台,旧 DOP 实际取号处) | 报检单 `RQLLJYSQ{yyyyMMdd}{4位}`(`lasttime=2026-08-11 13:09:37`,与本例报检单时间精确吻合);检验单 `IQC{yyyyMMdd}{4位}` |
+
+## 待验证项 → 已全部闭合(2026-08-11 静态取证)
+
+| # | 原待验证项 | 结论 |
+|---|-----------|------|
+| 1 | `PurOrdRctDetail.QCNbr/StatusByQC` 是否被写 | **不写**。门控实为 `PurOrdDetail.ReceiptQty`(非 `RctType`),本例 `=0` 故该分支不命中;P3 也不得补写 |
+| 2 | 箱码是否 `I→N` | **是**,合格分支下全部箱码统一置 `N`,同时 `QtyAccN=Qty`、`QtyRejectN=0` |
+| 3 | 是否产生 `MobileTask` | **不产生**,`AddMobileTask` 门控为 `@IsRctTemp>0` |
+| 4 | 库存是否离开 `1000` | **不离开**,IQC 不过账;靠 WMS APP「上架移库」移到 `1001` |
+| 5 | 检验单号格式 | `IQC`+`yyyyMMdd`+**4** 位,按日重置;走 `rf_serialnumber` 而非 `NbrControl` |
+| 6 | `IsPurOrdRctReconfirmQty` 是否介入 | **不介入**(无配置行,且仅在 `@IsRctTemp>0` 时生效,双重无关) |
+
+详细依据见任务书 §2.2 / §2.3 / §2.5 / §6.3 与 §7 P0 卡片。

+ 46 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/03-P1P2P3运行验收.md

@@ -0,0 +1,46 @@
+# P1–P3 运行验收(2026-08-11 16:50+)
+
+活样本:`PO202608110003` / `RC202608110001` / `260811013` / `RQLLJYSQ202608110001` / 箱码 `10003232-20260811-00010010001`  
+租户:`797403760988229`(`AIDOPDemo`)  
+后端:`Admin.NET.Web.Entry` `1.0.332` @ `http://localhost:5005`
+
+## P1 HotWatch 回读
+
+| 项 | 结果 |
+|----|------|
+| 现象 | `[MdpHotWatch] applied IQC purOrd=PO202608110003 … apply=1 entry=1 bill=0` |
+| 本库报检主表 | `qms_qcp_inspecapplyn` 有 `RQLLJYSQ202608110001`,`tenant_id=797403760988229` |
+| 本库报检分录 | `qms_qcp_insappnentry.id=459268028740784`,状态当时为 `未检验` |
+| 前置故障 | 启动曾因 `1.0.304.sql` SHA 与迁移日志不一致失败;恢复文件后正常;已清空 watch#18 `last_snapshot_hash` |
+
+## P2 认领 + 生成检验单
+
+| 步骤 | API | 结果 |
+|------|-----|------|
+| 列表 | `GET /api/S5IqcTaskEntry/list?ApplyBillNo=RQLLJYSQ202608110001` | total=1 |
+| 认领 | `POST …/claim` ids=`459268028740784` | `ownerId=798578309906501` |
+| 建单 | `POST …/generate-inspbill` | `inspBillNo=IQC202608110001`,`inspBillId=836702622240837` |
+
+备注:UAT 将 `AIDOPDemo` 绑定了 `ROLE_S5_IQC_*` 三角色(种子默认只绑 `superAdmin.NET`),否则无法起审批流。
+
+## P3 提交合格 → 165 箱码 I→N
+
+| 步骤 | 结果 |
+|------|------|
+| `POST /api/S5IqcInspBillFlow/submit-result` | `pd=0, dhsl=500, bhgsl=0` → 流程至 `N2_SUP_REVIEW` |
+| outbox `IQC_WMS_MP` | **status=1**,idem=`iqc\|mp\|RQLLJYSQ202608110001\|…0010001` |
+| outbox `IQC_WMS_MP_HIST` | **status=1** |
+| outbox `IQC_WMS_ENTRY` | **status=1**;165 分录 `FINSPECTSTATUS=检验完成` |
+| 165 `MissedPrint` | `…0010001` **Status `I`→`N`**,`UpdateUser=798578309906501`,`UpdateTime=2026-08-11 16:52:28` |
+| 165 `MissedPrintTransHist` | 新增 `TransType=来料检验-通过`,`Status=N`,`Remark=来料检验单:RQLLJYSQ202608110001` |
+
+### 已知次要失败(不挡 P3 主路径)
+
+| outbox | status | 说明 |
+|--------|--------|------|
+| `IQC_WMS_BILL` | 0 retrying | UPSERT `qms_qcp_inspbill` 的 insert 未带 `id`,165 表 `id` NOT NULL → INSERT 失败 |
+| `S5_IQC_RESULT_PUSH` | 0 | `QMS_API` 目标源未启用(占位,预期) |
+
+## P4 待办
+
+WMS APP 对箱码 `…0010001`(Status=`N`)执行上架;对照 [`00-基线快照.md`](./00-基线快照.md) 与任务书 §6.1 写 `10-P4端到端留证.md`。

+ 119 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/10-P4端到端留证.md

@@ -0,0 +1,119 @@
+# P4 / P5 端到端留证(QcCheck 上架过账)
+
+| 项 | 值 |
+|----|----|
+| 样本 | 箱码 `10003232-20260811-00010010001` / 批 `260811013` / 物料 `81HC0744` / PO `PO202608110003` |
+| 前置 | P1–P3 已通过,见 [`03-P1P2P3运行验收.md`](./03-P1P2P3运行验收.md) |
+| 任务书 | [`S5-IQC合格上架过账(QcCheck)执行任务书.md`](../../S5-IQC合格上架过账(QcCheck)执行任务书.md) |
+| 判据 | Ai-DOP 经 `op=PROC` 调 `pr_WMS_BPM_SaveInvUpShelf @TransCode=QcCheck`,库存 `1000`→`1001` |
+
+## 根因结论(APP 路径不可行)
+
+| 菜单 | 结果 | 原因 |
+|------|------|------|
+| 上架移库 | ❌ 非限制库存不足 | `@TransCode` 非 `QcCheck`,走 `iss-tr` 扣 `AvailStatusQty`(本样本为 0) |
+| 检验确认 | ✅ 提交成功但库存不动 | 只写标签流水,不过账 |
+| 检验后部分收货 | ❌ 要检验单号 | 甲路径 `QCNbr` 为空 |
+
+**正确入口**:`pr_WMS_BPM_SaveInvUpShelf @TransCode='QcCheck'` → `iss-tr-ins`。
+
+## 上架前快照(P3 之后)
+
+| 对象 | 值 |
+|------|-----|
+| `MissedPrint` | Status=`N` / Location=`1000` / Shelf=`01-01-01` / RecID=`126642` |
+| `LocationDetail` @1000 | QtyOnHand=500 / Assay=500 / AvailStatusQty=0 |
+
+## 执行与验收(2026-08-11 19:47)
+
+| # | 检查 | 实测 |
+|---|------|------|
+| 1 | `mdp_outbox` id=4763 | `status=1`,`returnMsg=保存成功!` |
+| 2 | `LocationDetail` @1000 | QtyOnHand=0 / Assay=0 / Avail=0 |
+| 3 | `LocationDetail` @1001 | QtyOnHand=500 / Assay=0 / **AvailStatusQty=500** |
+| 4 | `InvTransHist` | 2 行 `iss-tr-ins`:1000 −500、1001 +500,`OrdNbr=TA2026080003` |
+| 5 | `MissedPrint` | Location=`1001`、Shelf=`01-01-01`(保持)、OrdNbr=`TA…`、Status=`N` |
+| 6 | 幂等预检 | 标签已在 1001 时 Skip:`已是此货架(执行器预检)`,库存不再变动 |
+
+> 说明:首次人工复测未带预检时,同库位二次 QcCheck 曾短暂把 `Assay` 打成 −500 / `Avail` 打成 1000;已纠偏回 1001:`Assay=0,Avail=500`,并在执行器增加预检(见任务书修订)。
+
+## 状态
+
+✅ **P4/P5 通过**:质检库存已过账到合格库非限制库存;APP「上架移库」此时具备非限制库存前提(同库位换架可用)。
+
+---
+
+# 全链路复测:扫码收货 → 自动 IQC 报检 → 判定合格 → 上架入库(2026-08-11 23:07)
+
+本轮为**界面全程真人操作**的端到端复测(上一轮 P4/P5 上架段由脚本触发),用于验证认领覆盖修复后链路可从头走通。
+
+| 项 | 值 |
+|----|----|
+| 样本 | PO `PO202608110003` / 收货 `RC202608110002` / 批 `260811014` / 物料 `81HC0854` / 箱码 `10003232-20260811-00010020001`(500) |
+| 报检单 | `RQLLJYSQ202608110002`,分录 `459268028740785` |
+| 检验单 | `IQC202608110002`(`836793005760581`) |
+| 环境 | 后端 `1.0.334` 本机 5005 / 前端 `127.0.0.1:8888` / 租户 `797403760988229` / 账号 `Demo01`(`798578309906501`)/ 域 `8010` |
+| 前置 | 正式机那套抢同库的实例已停(见 [`30-多实例抢同库热回读事故.md`](./30-多实例抢同库热回读事故.md)),否则指纹互刷会持续重放 |
+
+## 步骤与实测
+
+| # | 操作 | 实测结果 | 时间 |
+|---|------|----------|------|
+| 1 | WMS APP 采购收货(扫码) | 箱码入 `1000`、`Status=I`、`LocationDetail@1000` `Assay=500`/`Avail=0`;`InvTransHist` 一行 `rct-po-ins` +500(`CreateUser=qm08`) | 20:56:05 |
+| 2 | HotWatch 自动报检回读 | 本库生成 `RQLLJYSQ202608110002` + 分录 `459268028740785` | 20:57 |
+| 3 | 界面认领 | `jyfzr=798578309906501`(Demo01),跨多轮回读未被抹 | 22:00 |
+| 4 | 界面「来料检验」生成检验单 | `IQC202608110002`,状态"检验中",报检/检验数量 500 | 22:59:29 |
+| 5 | 界面提交检验结果(判定合格 / 合格 500 / 不合格 0) | 提示"检验结果已提交(已推送回写 WMS),转检验主管审核";流程 `Running` / 节点"检验主管审核" / 角色 `ROLE_S5_IQC_SUPERVISOR` | 23:07:10 |
+| 6 | 界面「检验主管审核」点通过 | 流程状态 `Approved`、说明"流程已完成(主管审核通过)"、审核区块消失;不写 165、不动库存(符合设计) | 23:39 |
+
+截图:[`screenshots/20260811-2259-生成检验单-IQC202608110002.png`](./screenshots/20260811-2259-生成检验单-IQC202608110002.png)、[`screenshots/20260811-2306-提交检验结果合格-IQC202608110002.png`](./screenshots/20260811-2306-提交检验结果合格-IQC202608110002.png)、[`screenshots/20260811-2339-主管审核通过关单-IQC202608110002.png`](./screenshots/20260811-2339-主管审核通过关单-IQC202608110002.png)
+
+> 观察项(待决策,本轮未改):关单后流程状态为 `Approved`,但检验单 `FBILLSTATUS` 仍为「检验中」(`approve` 按设计不写 `qms`),界面「单据状态」因此一直停在"检验中",容易被误读为未结束。是否在关单时同步单据状态(或页面改按流程状态展示),需单独决策。
+
+## 回写与过账核对
+
+`mdp_outbox` 提交后入队 5 条,**全部 `status=1`(成功)、`retry_count=0`**:
+
+| id | 幂等键 | 作用 |
+|----|--------|------|
+| 4766 | `iqc\|mp\|RQLLJYSQ202608110002\|10003232-20260811-00010020001` | 箱码解禁(`Status` I→N) |
+| 4767 | `iqc\|mph\|…\|20260811230710` | 箱码历史 |
+| 4768 | `iqc\|entry\|459268028740785\|RQLLJYSQ202608110002` | 分录回写 |
+| 4769 | `iqc\|bill\|IQC202608110002` | 检验单回写 |
+| 4770 | `iqc\|upshelf\|RQLLJYSQ202608110002\|10003232-20260811-00010020001` | `op=PROC` 调 `pr_WMS_BPM_SaveInvUpShelf @TransCode=QcCheck` 上架过账 |
+
+165 侧逐处对照(均 23:07:10):
+
+| 对象 | 检验前 | 检验后 |
+|------|--------|--------|
+| `MissedPrint` 箱码 | `Status=I` / `Location=1000` | **`Status=N` / `Location=1001` / `Shelf=01-01-01`** / `CheckNo=RQLLJYSQ202608110002` |
+| `LocationDetail` @1000 | QtyOnHand=500 / `Assay=500` / Avail=0 | QtyOnHand=0 / Assay=0 / Avail=0 |
+| `LocationDetail` @1001 | — | **QtyOnHand=500 / Assay=0 / `AvailStatusQty=500`** |
+| `InvTransHist` | — | 两行 `iss-tr-ins`:`1000 −500`、`1001 +500`,`CreateUser=798578309906501` |
+| `qms_qcp_inspbill` `IQC202608110002` | — | `pd=0` / `dhsl=500` / `bhgsl=0` / `jyr=798578309906501` |
+| `qms_qcp_insappnentry` 批 `260811014` | `未检验` | `FINSPECTSTATUS=检验完成`、`jywcsj=2026-08-11 23:07`;`jyfzr` 保持 NULL(Ai-DOP 自有字段按字段归属矩阵不推 165) |
+
+## 缺口:S5 两个查询页读的是"冻结镜像",看不到今天的过账
+
+界面复测时按 `81HC0854` / `260811014` 在 **S5 库存查询(FUNC-S5-010)** 无论单填还是双填都查不到,排查结论如下(非操作方式问题)。
+
+| 数据 | 本库镜像 `aidopdev` | 165 真实库 |
+|------|---------------------|-----------|
+| `LocationDetail` 行数 / 物料数 | 56373 / 4157 | 56379 / 4160 |
+| `LocationDetail` 最新 `UpdateTime` | **2025-07-15 11:45** | **2026-08-11 23:07:10**(本次过账) |
+| `LocationDetail` 命中本样本 | 0 行 | `1001` 500 非限制 |
+| `InvTransHist` 行数 / 最新 `CreateTime` | 1001712 / **2025-12-31 09:57** | 含本次两行 `iss-tr-ins`(23:07:10) |
+
+`StockQueryService`(库存查询)与 `InoutQueryService`(出入库查询)都直读 `aidopdev` 的业务镜像表 `LocationDetail` / `InvTransHist`,而这两张镜像是历史一次性导入、之后**未常态同步**;MDP 侧虽有 `S5_LOCATION_DETAIL_SQLSERVER`、`S5_INV_TRANS_HIST_SQLSERVER` 等入站实体(`status=1`,`last_sync_to` 为空),但其目标是贴源层 `mdp_stg_inventory` / `mdp_stg_inv_trans`,与页面所读业务镜像表不是同一条链。
+
+结论:**过账本身已成功**(165 六处证据齐全),但 Ai-DOP 界面当前无法呈现该结果,"上架入库"的界面留证只能暂以 165 侧证据 + WMS APP(直读 165)截图替代。修复方案待决策,三条候选:① 过账成功后按"物料+批次+库位"窄回读同步这两张镜像(与 HotWatch PUR_ORDER 口径一致,改动最小);② 为两张表建常态化增量抽数并决定页面读镜像还是读贴源;③ 页面改为跨库直读 165(一致性最好,需评估性能与耦合)。
+
+## 已知非阻塞项
+
+`mdp_outbox` id=4765(幂等键 `IQC202608110002`,目标源 `QMS_API`,动作 `S5_IQC_RESULT_PUSH`)为 `status=0` 待处理,`error_msg=目标源 QMS_API 未启用`、`last_error_code=SOURCE_DISABLED`。当前链路走库级字段回写 + `op=PROC`,`QMS_API` 通道未启用属预期,不影响过账;若后续不再保留该通道,应在入队处按源启用状态跳过,避免长期堆积待处理消息。
+
+## 状态
+
+✅ **全链路通过并结案**:扫码收货 → 自动 IQC 报检 → 界面认领/生成检验单 → 判定合格 → QcCheck 上架入库 → 主管审核关单,质检库存 500 已转为合格库非限制库存,流程 `Approved`。
+
+遗留两项(均已单列、不阻塞结案):S5 两个查询页读冻结镜像看不到当日过账(见上文"缺口"小节,需口径收敛任务书);关单后 `FBILLSTATUS` 仍显示"检验中"(见步骤 6 观察项)。

+ 51 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/20-认领被回读覆盖修复验收.md

@@ -0,0 +1,51 @@
+# 认领被回读覆盖修复验收(2026-08-11)
+
+| 项 | 值 |
+|----|----|
+| 任务书 | [`S5-IQC热回读覆盖本地自有字段修复执行任务书.md`](../../S5-IQC热回读覆盖本地自有字段修复执行任务书.md) |
+| 后端 | `1.0.334` |
+| 活样本 | 分录 `459268028740785` / 报检单 `RQLLJYSQ202608110002` / 箱码 `…0010020001` |
+
+## 落地内容
+
+| 卡片 | 结果 |
+|------|------|
+| P1 分录 UPSERT | `jyfzr`/`yxj` 不参与回读;`FINSPECTSTATUS` 只许前进;`jykssj`/`jywcsj` 空值不覆盖 |
+| P1-b 脏值 | 导出后清理 2 行 `yxj=420171544903749` → NULL |
+| P2 检验单 | 165 侧缺 `IQC202608110001` 而本库有结果 → UPDATE 对 `dhsl/bhgsl/pd/clfs/FBILLSTATUS/jyr/FINSPECTORID/FINSPESTARTDATE` 空值不覆盖 |
+| P3 哈希 | 行序列化后排序再 SHA256(序无关),消除无变更重放 |
+| P4 文档 | 父书 §5.4.1、WP8 §4.4-6、00B §7-8;版本 `1.0.334` |
+
+## 验收步骤与实测(2026-08-11 夜,后端 `1.0.334`,前端 `127.0.0.1:8888` → 本机 `127.0.0.1:5005`)
+
+| # | 步骤 | 实测结果 | 时间 |
+|---|------|----------|------|
+| 1 | 认领分录 `459268028740785` | `jyfzr=798578309906501`(Demo01) | 22:00 |
+| 2 | 等 ≥60 秒观察 | 跨 7 轮 HotWatch 仍为 `798578309906501`;`yxj` 保持 NULL | 22:04 |
+| 3 | 点「来料检验」生成检验单 | **不再报「尚未认领或非认领人操作!」**;返回 `{inspBillId:836793005760581, inspBillNo:"IQC202608110002"}` | 22:59:29 |
+| 4 | 复核分录状态 | `FINSPECTSTATUS=检验中`、`ownerName=Demo01`;`jyfzr` 未被抹 | 23:00 |
+| 5 | 观察重放收敛 | 见下节:外部实例停掉后 `loaded == computed` 连续稳定、`applied IQC` 重放归零、单轮耗时 40s → 9s | 22:57 起 |
+
+截图:[`screenshots/20260811-2259-生成检验单-IQC202608110002.png`](./screenshots/20260811-2259-生成检验单-IQC202608110002.png)
+
+检验单落地要点:状态「检验中」、报检/检验数量 500、检验员 Demo01、`bizType=S5_IQC_INSPBILL`;流程状态 `NotStarted` / `canSubmitResult=true`("待检验员录入结果"),录入面板可见。
+
+## 第 5 步为何一开始不收敛(重要)
+
+改完 P3 序无关哈希后,`changed` 仍每轮 14–18、`applied IQC` 仍每轮重放,但库里 `last_snapshot_hash` 却长期不变。加一行临时探针日志打"库里已存 / 本轮新算"后一次定性:
+
+```
+[MdpHotWatch] hash probe key=PO202608110003 loaded=1C4EF09A… computed=F25586BE… parts=12
+```
+
+本机永远算 `F25586BE…`,库里被写成 `1C4EF09A…` —— **是另一套后端实例在轮询同一个库**,两边指纹互相覆盖,谁都判定"变了"。补充证据:把本机后端整个停掉后,`ado_mdp_hot_watch.last_poll_time` 仍在推进(22:16→22:18 连续写),而本机已无任何 dotnet 进程。详见 [`30-多实例抢同库热回读事故.md`](./30-多实例抢同库热回读事故.md)。
+
+外部实例于 22:57 前停掉,最后一次干扰 22:52:42;之后连续十余轮 `loaded == computed`,无 `applied IQC` 重放。
+
+## 状态
+
+**已通过**:D1(认领/优先级不再被回读抹除)代码 + 界面双验收完成;D2 的真实根因为多实例抢库,非行序抖动(P3 序无关哈希仍保留,本机指纹因此稳定)。修复后全链路已界面复测通过,见 [`10-P4端到端留证.md`](./10-P4端到端留证.md) 后半段。
+
+## 收尾
+
+- 临时 `hash probe` 日志已于 E2E 跑通后从 `MdpHotWatchService.cs` 移除;移除后重启(构建 `1.0.335`,最终提交 `1.0.336`)连续 4 分钟日志中 `hash probe` 0 条、`applied IQC` 仅 1 条(无重放),见 [`30-多实例抢同库热回读事故.md`](./30-多实例抢同库热回读事故.md) 第三节验收。

+ 118 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/30-多实例抢同库热回读事故.md

@@ -0,0 +1,118 @@
+# 多实例抢同库热回读 + 迁移脚本行尾事故(2026-08-11)
+
+本文件记录 2026-08-11 夜 IQC E2E 验收过程中拖住进度的三个**环境级**问题(都不是业务代码逻辑缺陷),供后续排障与规范收口引用。
+
+| 项 | 值 |
+|----|----|
+| 开发库 | MySQL `123.60.180.165` / `aidopdev`(`Database.json` 当前生效串,注释为"Dev 165") |
+| 本机后端 | `1.0.334`(第三节处置后 `1.0.336`),`http://127.0.0.1:5005`,前端 dev `127.0.0.1:8888`(`vite.config.ts` 代理固定本机) |
+| 活样本 | `PO202608110003` / 分录 `459268028740785` / 报检单 `RQLLJYSQ202608110002` / 检验单 `IQC202608110002` |
+
+## 一、多实例抢同一个库跑热回读
+
+### 现象
+
+- `MdpHotWatchWorker` 每轮 `changed=14~18`(几乎全量"有变更"),`applied IQC purOrd=PO202608110003` 每 ~40 秒重放一次;
+- 但 `ado_mdp_hot_watch.last_snapshot_hash` 在库里长期不变,"值没变却每轮判定变更"自相矛盾;
+- 排除过:脚本/DLL 陈旧(DLL 21:47 晚于源码 21:46)、列被截断(`varchar(64)`、实际长度 64)、写库未生效(哨兵值 `REPEAT('X',64)` 一轮内被覆盖回来)、`ForceRepoll` 被前端周期调用(全仓无调用)。
+
+### 定性证据
+
+临时探针日志(`MdpHotWatchService.cs`,仅 `PUR_ORDER`):
+
+```
+[MdpHotWatch] hash probe key=PO202608110003 loaded=1C4EF09A… computed=F25586BE… parts=12
+```
+
+- 本机每轮算出的恒为 `F25586BE…`,而库里被写成 `1C4EF09A…`;
+- **把本机后端全部停掉后**(22:16 起本机无 dotnet 进程),`last_poll_time` 仍在推进:22:16:51 → 22:17:46 → 22:18:35;
+- `information_schema.PROCESSLIST` 中 `aidopdev` 有三个客户端 IP:`39.105.125.212`(预发/正式主机)、`111.32.80.228`、`49.65.172.28`。
+
+### 根因
+
+正式发布服务器上另跑着一套 AI-DOP,其 `Database.json` 仍指向开发库 `123.60.180.165/aidopdev`,于是两套实例轮询同一批 `ado_mdp_hot_watch`:各自算出的快照指纹不同 → 互相覆盖 → 双方每轮都判定"有变更" → 无谓重放 UPSERT / 贴源 / IQC 回写;若对端为**未含字段守护的旧代码**,还会抹掉 Ai-DOP 自有字段(如 `jyfzr`)。
+
+### 处置与效果
+
+对端实例于 22:57 前停掉,最后一次干扰 22:52:42。之后本机连续十余轮 `loaded == computed`,`applied IQC` 重放归零,单轮耗时由 ~40 秒降到 ~9 秒。
+
+### 遗留建议(未实施,待决策)
+
+1. 正式机重新拉起前,把连接串切回自己那套库(`Database.json` 里注释的 `39.105.125.212/aidopdev`)——它不仅抢指纹,其定时任务与 outbox 回写 165 WMS 也在动开发数据;
+2. 同一库同一时刻只允许一个实例跑热回读与定时任务:加环境开关或库级选主锁。同类问题在 [`../../S8/S8数据库处理优化方案.md`](../../S8/S8数据库处理优化方案.md)(多实例重复调度,列出的 IP 同样包含 `39.105.125.212`)已记录过一次。
+
+## 二、已执行的迁移脚本被改动行尾 → 后端反复启动失败
+
+### 现象
+
+`AutoVersionUpdate 执行失败:版本脚本 1.0.304.sql 的 SHA256 与已成功记录不一致,禁止静默覆盖。` 反复出现,且"修好后再 build 又复发"。
+
+### 根因
+
+| 来源 | SHA256 |
+|------|--------|
+| `sys_db_migration_log`(2026-08-09 Success) | `253A59CF612DF71FF6D603004DABEF370433BC573947E3F6EE57F92DAE0376A4`(LF 版本) |
+| 工作区文件(被改成 CRLF) | `24867D70055F6B449985DF77E21FF62145472A18E00406E57863A0B5E96DB13C` |
+
+内容一字未改,**只是行尾从 LF 变成 CRLF**,文件哈希即变,守卫按设计拦下启动。此前"改 `bin` 目录下副本"的处置无效:任何 `dotnet build` 都会用源文件重新覆盖 `bin\...\UpdateScripts\`,所以一重建就再次失败。
+
+### 处置
+
+将 `server/Admin.NET.Web.Entry/UpdateScripts/1.0.304.sql` 转回 LF(哈希已对上库记录,`git status` 干净),并体检全部 106 个脚本:均为 LF,无同类隐患。
+
+### 规范
+
+**已执行成功的版本脚本,连行尾都不能改**(等价于改内容)。需要调整必须新建更高版本脚本;排障时应比对"库记录哈希 / 源文件哈希 / bin 副本哈希"三者,不要只改 bin 副本。
+
+## 三、贴源表缺失 + 迁移脚本未登记 csproj → 热回读整批 poll 中断
+
+### 现象
+
+23:0x 生成检验单 `IQC202608110002` 之后,`MdpHotWatchWorker` 每轮告警 `poll failed`:
+
+```
+MySqlConnector.MySqlException: Table 'aidopdev.mdp_stg_iqc_pull' doesn't exist
+  at MdpStagingWriter.UpsertAsync(...)
+  at MdpHotWatchService.PollOnceAsync(...)
+```
+
+贴源写入在 `ApplyIqcAsync` 之前,异常直接掀掉当轮整批 watch —— 也就是检验单一出现,热链路就全线停摆(此前 `qms_qcp_inspbill` 无行,故未暴露)。
+
+### 根因
+
+| 缺口 | 说明 |
+|------|------|
+| 贴源表未建 | `mdp_entity.S5_IQC_INSPBILL_SQLSERVER` 已 `status=1` 且 `target_table_name='mdp_stg_iqc_pull'`,但库中只有 `mdp_stg_fqc_pull` / `mdp_stg_ipqc_pull`,IQC 那张从未建 |
+| 脚本未登记 | IQC `biz_key_expr` 修正脚本(把 `Domain,BillNbr` 改为 `FBILLNO`)没写进 `Admin.NET.Web.Entry.csproj` 的 `<None Update=... CopyToOutputDirectory>`,既没拷进 `bin`,也从未执行(启动日志"发现脚本 92 个,待执行 0 个",`sys_db_migration_log` 无对应记录)。该脚本当晚编号 `1.0.332`,推送前发现与远端 S0 那批的 `1.0.332.sql` **撞号不同内容**,最终改为 `1.0.336.sql`(详见本节"撞号"小节) |
+
+`biz_key_expr` 错配还有次生影响:`Domain,BillNbr` 在 `qms_qcp_inspbill` 里都不存在,`BuildBizKey` 回落到 `source_row_id`(= 采购单号),同一采购单的多张检验单会在唯一键 `(source_system, source_table, source_biz_key)` 上互相覆盖。
+
+### 处置
+
+1. 新增 `UpdateScripts/1.0.335.sql`:按 `mdp_stg_fqc_pull` 同构建 `mdp_stg_iqc_pull`(`CREATE TABLE IF NOT EXISTS`,可重复执行);
+2. `Admin.NET.Web.Entry.csproj` 补登记 `biz_key_expr` 修正脚本与 `1.0.335.sql`,后端版本递增(`AutoVersionUpdate` 只执行 `脚本版本 <= 程序版本`,脚本号必须不高于当前版本才会跑)。
+
+### 验收(00:10 起,构建 `1.0.335`;因撞号最终提交为 `1.0.336`)
+
+```
+[HANDLE] 当前版本:1.0.335,目标数据库:aidopdev
+[HANDLE] 执行版本 1.0.332 ... 成功
+[HANDLE] 执行版本 1.0.335 ... 成功
+[MdpHotWatch] applied IQC purOrd=PO202608110003 domain=8010 apply=2 entry=3 bill=2
+```
+
+连续 4 分钟:`poll failed` 0 次、`applied IQC` 仅 1 次(无重放)、临时探针日志已移除(`hash probe` 0 次)。贴源层落地正确:
+
+| source_table | source_biz_key | tenant_id | process_status |
+|---|---|---|---|
+| qms_qcp_inspbill | IQC202608110002 | 797403760988229 | PENDING |
+
+### 撞号:同一版本号两份脚本
+
+推送前 `git pull --rebase` 发现远端已并入 S0 那批的 `UpdateScripts/1.0.332.sql`(EmployeeMaster 补列),与本机同号不同内容。后果比冲突本身严重:本机已把**自己那份** 1.0.332 以 Success 记进开发库 `sys_db_migration_log`,任何拿到远端代码的实例启动时都会撞上"SHA256 与已成功记录不一致"守卫而**起不来**。
+
+处置:远端 1.0.332 保留原样,本机脚本改号为 `1.0.336.sql`;开发库中那条按本机脚本写入的 1.0.332 记录删除,让远端 S0 脚本在本库正常首次执行(本机脚本内容为幂等 UPDATE,改号后重跑无副作用)。最终版本:后端 `1.0.336`、前端 `2.4.286`。
+
+### 规范
+
+新增 `UpdateScripts/*.sql` 必须同时做四件事,缺一即"静默不执行"或"炸别人的启动":**① 取号前先 `git fetch` 看远端最大脚本号,避免撞号;② 在 `.csproj` 登记 `CopyToOutputDirectory=Always`;③ 脚本版本号 ≤ 当前 `<Version>`(否则连同版本一起升);④ 启动后查 `sys_db_migration_log` 确认有 Success 记录**。启用一个 `mdp_entity` 前,先确认其 `target_table_name` 指向的贴源表已存在。

+ 34 - 0
doc/plan/UAT留证/2026-08-11-IQC-P0取证/README.md

@@ -0,0 +1,34 @@
+# 2026-08-11 IQC 取证与验收留证
+
+> ⚠️ **原目标(在旧 DOP 跑通一次真实来料检验并抓变动列)已作废**:旧 DOP 已停运,仅作移植参考,不得要求任何旧 DOP 上的操作。
+>
+> P0 取证改为 **静态取证**(直读 165 低代码元数据 + 存储过程定义),已于 2026-08-11 完成,产出直接写入任务书正文,**不在本目录另出交付物**。
+
+| 项 | 内容 |
+|----|------|
+| 本目录现用途 | ① 保存检验前基线快照,供 **P3/P4 验收逐列对照**;② 存放 P4 端到端验收的步骤留证 |
+| 任务书 | [`../../S5来料检验IQC链路打通方案与执行任务书.md`](../../S5来料检验IQC链路打通方案与执行任务书.md) |
+| P0 产出位置 | 任务书 §2.2(五分支判定矩阵)、§2.3(写入门控与两条收货路径)、§2.5(旧系统行为规格)、§6.3(箱码赋值规则) |
+| 活样本(首轮) | `PO202608110003` / `RC202608110001` / 批号 `260811013` / 箱码 `...0010001` / 报检单 `RQLLJYSQ202608110001` |
+| 活样本(08-11 夜 E2E 复测) | `PO202608110003` / `RC202608110002` / 批号 `260811014` / 箱码 `10003232-20260811-00010020001`(500) / 物料 `81HC0854` / 报检单 `RQLLJYSQ202608110002` / 分录 `459268028740785` / 检验单 `IQC202608110002`(`836793005760581`) |
+| 环境 | 租户 `797403760988229`(AIDOP)/ 账号 `Demo01`(`798578309906501`)/ 域 `8010` / 后端 `1.0.336` 本机 5005 / 库 `123.60.180.165 aidopdev` |
+| 状态 | **P1–P5 已通过**;认领被热回读覆盖已修并界面验收(见 [`20`](./20-认领被回读覆盖修复验收.md));环境级事故两则见 [`30`](./30-多实例抢同库热回读事故.md);**全链路界面复测已通过**(扫码收货→自动报检→判定合格→上架入库,见 [`10`](./10-P4端到端留证.md) 后半段) |
+
+## 为什么静态取证反而更完整
+
+跑一次实例只能覆盖**一条**判定路径(合格);静态取证拿到的是 `qms_WMS_SaveIQCResult` / `qms_WMS_SaveIQCcheck` **全部五个分支**(合格、合格挑选、退货、让步接收、不合格挑选)的参数与箱码状态规则,外加检验单号规则、任务列表四个按钮的原文 SQL、工作流写回挂点。原计划中六项「关键待验证」已逐条闭合,见任务书 §7 P0 卡片。
+
+## 文件
+
+| 文件 | 内容 | 状态 |
+|------|------|------|
+| [`00-基线快照.md`](./00-基线快照.md) | 检验前 9 表 + 配置开关的逐列取值 | ✅ 有效,用途转为 P3/P4 对照基线 |
+| [`03-P1P2P3运行验收.md`](./03-P1P2P3运行验收.md) | HotWatch 回读 / 认领建单 / 提交合格→165 箱码 I→N | ✅ 2026-08-11 已跑通 |
+| ~~`01-步骤1-派工后.md`~~ | ~~旧 DOP 派工后 diff~~ | ❌ 作废(旧 DOP 停运,未创建) |
+| ~~`02-步骤2-判定合格后.md`~~ | ~~旧 DOP 判定后 diff~~ | ❌ 作废(未创建) |
+| ~~`03-步骤3-审核后.md`~~ | ~~旧 DOP 审核后 diff~~ | ❌ 作废(未创建) |
+| ~~`04-变动列清单.md`~~ | ~~P1–P3 强制前置~~ | ❌ 作废:**P1–P3 已无外部前置**,规格在任务书内 |
+| [`10-P4端到端留证.md`](./10-P4端到端留证.md) | P4/P5:QcCheck 上架过账 + 状态机对照 | ✅ APP 三菜单不可行;PROC 过账已验收 |
+| [`20-认领被回读覆盖修复验收.md`](./20-认领被回读覆盖修复验收.md) | HotWatch 不得抹认领人/优先级;序无关哈希 | ✅ 代码 `1.0.334` + 界面实测通过(含生成检验单截图) |
+| [`30-多实例抢同库热回读事故.md`](./30-多实例抢同库热回读事故.md) | 三则环境级事故:正式机另一套实例轮询同一开发库;迁移脚本行尾致启动失败;IQC 贴源表缺失 + 脚本未登记 csproj 致 poll 中断 | ✅ 均已定性并处置(第三则由 `1.0.335`/`1.0.336` 修复),多实例遗留规范建议待决策 |
+| [`screenshots/`](./screenshots/) | 界面留证截图 | 持续补充 |

BIN
doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2259-生成检验单-IQC202608110002.png


BIN
doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2306-提交检验结果合格-IQC202608110002.png


BIN
doc/plan/UAT留证/2026-08-11-IQC-P0取证/screenshots/20260811-2339-主管审核通过关单-IQC202608110002.png


+ 3 - 1
doc/plan/旧DOP-MES-WMS/对接任务书/00-总体方案.md

@@ -172,7 +172,9 @@
 | 域 | 真源 | 说明 |
 |----|------|------|
 | 计划域(订单、排产、齐套、采购申请) | **新 Ai-DOP MySQL** | 结果回写 165 供 MES/WMS 消费 |
-| 执行域(收发存、标签、报工、检验) | **165 dopdemorq** | 新系统只入站,不回写执行字段 |
+| 执行域(收发存、标签、报工、**检验***) | **165 dopdemorq** | 新系统只入站,不回写执行字段;***检验例外**见下 |
+
+> **检验例外(2026-08-11,方案 B / S5 IQC)**:来料检验判定权威落在 Ai-DOP;允许按字段级回写 165 的 `MissedPrint` / `MissedPrintTransHist` / `qms_qcp_insappnentry` / `qms_qcp_inspbill`(白名单见 [`S5来料检验IQC链路打通方案与执行任务书.md`](../../S5来料检验IQC链路打通方案与执行任务书.md) §6.3)。不写库存过账表、不写 `PurOrdRctDetail.QCNbr/StatusByQC`、不调 165 存储过程。其余执行域字段本例外不适用。
 | 主数据(物料、供应商、客户、库位) | **165 dopdemorq** | 新系统只入站 |
 | 单号序列 | **165 NbrDayInfo** | 双方共用 |
 

+ 24 - 3
doc/plan/旧DOP-MES-WMS/对接任务书/00B-执行须知(模型输入契约).md

@@ -126,6 +126,20 @@ DataPlatform/Executors/
 
 2. **旧 C# 与存储过程冲突时,以 C# 为准。** C# 才是旧系统实际运行的代码。已知旧 C# 中被注释掉的:`MobileTask` 推送、`QadTracking` 写入、`pr_WMS_AddWorkOrdSeq`。
 
+3. **旧 DOP 已停运(2026-08-11 确认),仅作移植参考。** 任何任务书、验收步骤、复现指引中**都不得出现「在旧 DOP 上操作」**。需要旧系统行为证据时,只能走**静态取证**(读旧仓源码、读 165 存储过程定义、读低代码元数据)。若某项验证只能靠实操旧 DOP 才能完成,说明该设计有问题,应改为静态取证,或改用「WMS APP 能否继续作业」作为等价验收判据(WMS APP 仍在运行,165 也是活库)。
+
+4. **旧仓源码里搜不到 ≠ 该功能不存在。** 旧 DOP 有相当一部分业务是**低代码平台的动态表单与工作流**,实现存放在 165 的元数据表里,不在 `ZZYDOP_OLD` 仓库中。已确认来料检验(IQC)全链路即属此类——全仓搜 `qms_qcp_inspbill` 零命中,实际实现在低代码元数据里。定位路径:
+
+   ```
+   rf_menu(type=0).applibraryid → rf_applibrary.address = /program/run/index?programid=N
+     → rf_program(N).sqlstring        -- 列表查询 SQL
+     → rf_program_button.clientscript -- 按钮动作(常见形式:utils.execdb('dopflow', sql) 直接下 SQL)
+     → rf_flow.designjson / runjson   -- 工作流节点,节点 event 里挂 submitBefore/submitAfter 存储过程
+     → rf_serialnumber                -- 单号规则(注意:与 165 WMS 侧的 NbrControl 是两套独立机制)
+   ```
+
+   实例参见 [`../../S5来料检验IQC链路打通方案与执行任务书.md`](../../S5来料检验IQC链路打通方案与执行任务书.md) §2.5。
+
 ---
 
 ## 5. 产出规范
@@ -134,7 +148,7 @@ DataPlatform/Executors/
 
 一律写成新的 UpdateScript:`server/Admin.NET.Web.Entry/UpdateScripts/<新版本号>.sql`
 
-- 当前最新脚本:`1.0.296.sql`;`csproj` 版本:`1.0.299`
+- 当前最新已编号脚本:`1.0.328.sql`;`csproj` 版本:`1.0.331`(2026-08-11)。目录下另有未编号的 `WIP-S5-IQC-INVPOSTING.sql`(S5 IQC 过账骨架),**勿复用、勿修改**
 - 取一个**大于现有全部脚本**的版本号,不要复用或修改已有脚本
 - 脚本必须**可重复执行**(`IF NOT EXISTS` / `WHERE NOT EXISTS` 保护)
 - **不要**在脚本里写明文密码
@@ -149,7 +163,9 @@ DataPlatform/Executors/
 | 含 `Web/` 下影响运行的改动 | `Web/package.json` 的 `version` patch +1 |
 | 仅文档 / 规则 | **不升版本号** |
 
-当前:后端 `1.0.299`,前端 `2.4.275`。
+当前(截至 2026-08-11,HEAD `b58e50dfd`):后端 **`1.0.331`**,前端 **`2.4.283`**。
+
+> 本节的版本号是**易过期信息**,开工前请以仓库实际值为准:`server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 与 `Web/package.json`。
 
 ### 5.3 造数打标
 
@@ -168,7 +184,7 @@ DataPlatform/Executors/
 | # | 检查 | 命令 / 方式 |
 |---|------|-----------|
 | 1 | 后端能编译 | `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` |
-| 2 | **全仓无存储过程调用**(硬约束 C2) | `rg -n "EXEC\s+pr_\|CALL\s+pr_" server/ Web/` 应无命中 |
+| 2 | **全仓无擅自调存储过程**(硬约束 C2;含 QcCheck 白名单例外) | `rg -n "EXEC\s+pr_\|CALL\s+pr_" server/ Web/`:命中只允许在 `MdpDbPushExecutor` 的 `op=PROC` 实现;过程名仅 `pr_WMS_BPM_SaveInvUpShelf` |
 | 3 | 无明文密码入库 | `rg -n "dopsa" server/ --glob "!*.md"` 人工确认无密码 |
 | 4 | UpdateScript 版本号未与现有冲突 | `ls server/Admin.NET.Web.Entry/UpdateScripts/` |
 | 5 | 版本号已按 §5.2 递增 | 查 `csproj` / `package.json` |
@@ -180,11 +196,16 @@ DataPlatform/Executors/
 
 1. **不改 165 的任何结构**:表结构、存储过程、触发器、索引、作业、配置数据(`NbrControl` 既有行也不改)。
 2. **不在新系统里调用或新建存储过程**。旧逻辑一律 C# 重写。
+
+   > **已批例外(2026-08-11):S5 IQC 合格上架过账(QcCheck)**。经决策 Q9,`MdpDbPushExecutor` 允许受限 `op=PROC`,过程名白名单**仅** `pr_WMS_BPM_SaveInvUpShelf`(见 [`S5-IQC合格上架过账(QcCheck)执行任务书`](../../S5-IQC合格上架过账(QcCheck)执行任务书.md) §3 Q9 / §4)。**仍禁止在 165 上新建或修改任何存储过程**;白名单增过程须同级评审。自检:`rg -n "EXEC\s+pr_|CALL\s+pr_" server/` 的命中只允许出现在该执行器的 `op=PROC` 实现中。
 3. **回写不得整行覆盖**。只允许 UPDATE 白名单列,且必须带业务键;`keys` 为空的 UPDATE 直接抛异常。
 4. **不新增旧 DOP 没有的自动回写闭环**(如"报工完成自动下一道领料")。属新能力,需另立需求。
+
+   > **已批例外(2026-08-11):S5 来料检验(IQC)**。经决策 Q1,IQC 判定权威移至 Ai-DOP,判定合格后自动回写 165。**例外范围严格限于** [`S5来料检验IQC链路打通方案与执行任务书`](../../S5来料检验IQC链路打通方案与执行任务书.md) §6.3 的字段共管矩阵白名单列;该文档 §6.3 的「禁止写清单」(`InvTransHist`、`LocationDetail`、`PurOrdRctMaster`、`PurOrdDetail.ReceiptQty`)继续受本条约束。**其余业务场景本条禁令照旧生效**,新增闭环仍须另立需求。
 5. **不开"业务页面直读 165"的旁路**。业务消费统一走 `mdp_std_*`。
 6. **不做超出本工作包范围的改动**。发现相邻问题写进交付说明,不要顺手改。
 7. **不擅自 `git push`**。除非任务明确要求。
+8. **新增热回读表必须先出字段归属矩阵**。在写任何 `ON DUPLICATE KEY UPDATE` 之前,逐列判定权威方(165 / Ai-DOP 独占 / 双方),Ai-DOP 独占列不得回读覆盖,双方列须有空值/回退守护。范例与已落地修复见 [`S5-IQC热回读覆盖本地自有字段修复执行任务书`](../../S5-IQC热回读覆盖本地自有字段修复执行任务书.md) §4。
 
 ---
 

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

@@ -27,6 +27,7 @@
 | [`00B-执行须知(模型输入契约).md`](./00B-执行须知(模型输入契约).md) | **派发任何工作包时必须一并交付**:连接、代码落点、产出规范、自检、禁止事项 | — |
 | [`执行进度-2026-08-09.md`](./执行进度-2026-08-09.md) | **本轮已落地内容与阻断项**(后端 1.0.301 / 前端 2.4.276) | — |
 | [`附录A-手册操作覆盖矩阵.md`](./附录A-手册操作覆盖矩阵.md) | **操作手册 49 个操作逐条 → 承接工作包**,判定「是否满足手册」的总账 | — |
+| [`../../S5来料检验IQC链路打通方案与执行任务书.md`](../../S5来料检验IQC链路打通方案与执行任务书.md) | **IQC 判定权威移至 Ai-DOP + 字段级回写 165**(Q1–Q4 已决)。**持有 `00B` §7.4 与 `00-总体方案` §4.3 的已批例外**,改这两处定案前必读;其 §11 为可派发的执行者输入契约 | WP2、WP3、WP8 |
 | [`WP1-入站抽数补齐.md`](./WP1-入站抽数补齐.md) | **冷链**:启用 165 源 + 36 张表的实体与字段映射 | — |
 | [`WP2-单号服务.md`](./WP2-单号服务.md) | C# 复刻 `pr_SFM_GetOrdNbr`,与 WMS APP 共用计数器 | — |
 | [`WP3-回写执行器.md`](./WP3-回写执行器.md) | `MdpDbPushExecutor` + Outbox 分发 + **出站事件驱动** + 写权限验证 | WP1 的源配置 |

+ 1 - 1
doc/plan/旧DOP-MES-WMS/对接任务书/WP5-主链路端到端验证.md

@@ -84,7 +84,7 @@ APP 提交后,165 会把 `MobileTask` 那一行**物理删除**并写入 `Mobi
 | 2 | 生成交货单 | 新 Ai-DOP | `srm_polist_ds` INSERT | 与 PO 关联正确 |
 | 3 | 推送待收货任务 | 新 Ai-DOP | `MobileTask` UPSERT(`Name='PurOrdRctBarcode'`、`TaskID='<送货单号>_<PurOrd>'`) | 见 WP7 §4.3 场景 1;**若送货单不落 165,走 B1 决策的替代路径** |
 | 4 | PDA 收货扫码 | WMS APP | 写 `PurOrdRctMaster/Detail`、`PurOrdDetail.RctQty`、`srm_polist_ds.sentqty`、`MissedPrint` + 库存 | APP 能看到任务与 PO |
-| 5 | IQC 判定 | WMS APP | 写 `qms_qcp_inspbill`、标签状态 | 不合格品冻结生效 |
+| 5 | IQC 判定 | **Ai-DOP**(来料检验任务列表;旧 DOP 已停运) | 本库判定后字段级回写 165:`MissedPrint`/`MissedPrintTransHist`/`qms_qcp_*` | 箱码解除待检(`I→N`);合格入库数仍等上架后出数 |
 | 6 | PDA 上架(先扫货架码再扫外箱码) | WMS APP | 写 `LocationDetail`、`MissedPrint` | **依赖 `LocationShelfMaster` 已回写**(WP7) |
 | 7 | 入站回读 | 新 Ai-DOP | `PurOrdRct*`、`RctQty`、`sentqty`、`qms_qcp_inspbill`、`LocationDetail` | 与 165 一致 |
 

+ 1 - 0
doc/plan/旧DOP-MES-WMS/对接任务书/WP8-实时回读热链路.md

@@ -153,6 +153,7 @@ DateTime start = DateTime.Now.Date.AddDays(-1);
 3. 轮询服务与冷链路**可能同时写同一张 `mdp_stg_*`**,需约定:热链路只更新执行侧字段,不做全量 replace,避免与 `RunSourceSwitchFullAsync` 的 FULL 重建打架(见 WP1 §3.5)。
 4. 单轮超时保护:一轮未跑完不叠加下一轮(`Concurrent = false`)。
 5. 165 连接抖动时退避重试,不要让关注表堆积。
+6. **回读不得覆盖 Ai-DOP 自有字段**。落到本库业务表的 UPSERT 须先做字段归属:165 权威列可覆盖;Ai-DOP 独占列(认领人、本库优先级等)不参与回读;双方都写的列须空值不覆盖、状态只许前进。快照哈希应对行序无关,避免无变更重放。详见 [`S5-IQC热回读覆盖本地自有字段修复执行任务书`](../../S5-IQC热回读覆盖本地自有字段修复执行任务书.md)。
 
 ---
 

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

@@ -0,0 +1,72 @@
+"""One-off: fix MissedPrint Carton='UAT01' that breaks MES 报工 convert(int,Carton)."""
+from __future__ import annotations
+
+import pyodbc
+
+import os
+
+_PWD = os.environ.get("SQLSERVER_PASSWORD")
+if not _PWD:
+    raise SystemExit("Set SQLSERVER_PASSWORD in env (do not hardcode).")
+CANDIDATES = [
+    f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER=123.60.180.165,1433;DATABASE=dopdemorq;UID=dopsa;PWD={_PWD};TrustServerCertificate=yes;",
+    f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER=123.60.180.165,1433;DATABASE=dopdemorq;UID=dopsa;PWD={_PWD};TrustServerCertificate=yes;",
+    f"DRIVER={{SQL Server}};SERVER=123.60.180.165,1433;DATABASE=dopdemorq;UID=dopsa;PWD={_PWD};",
+]
+
+
+def main() -> None:
+    conn = None
+    last_err = None
+    for cs in CANDIDATES:
+        try:
+            conn = pyodbc.connect(cs, timeout=10)
+            print("connected")
+            break
+        except Exception as e:  # noqa: BLE001
+            last_err = e
+            print("fail", type(e).__name__, str(e)[:160])
+    if conn is None:
+        raise SystemExit(f"connect failed: {last_err}")
+
+    cur = conn.cursor()
+    cur.execute(
+        """
+        UPDATE MissedPrint
+        SET Carton = CASE RecID
+                WHEN 126639 THEN '99901'
+                WHEN 126640 THEN '99902'
+            END,
+            UpdateTime = GETDATE(),
+            UpdateUser = 'AIDOPUAT'
+        WHERE RecID IN (126639, 126640) AND Carton = 'UAT01'
+        """
+    )
+    print("MissedPrint updated", cur.rowcount)
+    cur.execute(
+        """
+        UPDATE MissedPrintTransHist
+        SET Carton = CASE
+                WHEN BarCode = '1A03N54-20260811-UAT01' THEN '99901'
+                WHEN BarCode = 'A2C3115-20260811-UAT01' THEN '99902'
+                ELSE Carton
+            END,
+            UpdateTime = GETDATE(),
+            UpdateUser = 'AIDOPUAT'
+        WHERE BarCode IN ('1A03N54-20260811-UAT01', 'A2C3115-20260811-UAT01')
+          AND Carton = 'UAT01'
+        """
+    )
+    print("MissedPrintTransHist updated", cur.rowcount)
+    conn.commit()
+    cur.execute(
+        "SELECT RecID, BarCode, Carton, Status FROM MissedPrint WHERE RecID IN (126639,126640)"
+    )
+    for row in cur.fetchall():
+        print(tuple(row))
+    conn.close()
+    print("done")
+
+
+if __name__ == "__main__":
+    main()

+ 1 - 1
doc/plan/旧DOP-MES-WMS/对接任务书/附录A-手册操作覆盖矩阵.md

@@ -183,7 +183,7 @@ EmpWorkDutyMaster
 | 解冻 | 读 | 回读标签状态 | WP1 | 已覆盖 |
 | 报废 / 调拨 | 读 | 回读 `InvTransHist` | WP1 | 已覆盖 |
 | 待发货 / 发货确认 | 读 | 回读 `ASNBOLShipper*.RealQty/Status`;**ASN 由 WMS 侧自建,Ai-DOP 不回写**(A1 已结,见 §7) | WP1 | 已覆盖 |
-| IQC | 供/读 | 回读 `qms_qcp_inspbill`、`PurOrdRct*` | WP1 | 已覆盖 |
+| IQC | **判定在 Ai-DOP** + 回读 | Ai-DOP 判定后字段级回写 165;热关注回读 `qms_qcp_*`/`MissedPrint` | S5 IQC 任务书 / WP8 | 2026-08-11 改定案(方案 B) |
 | 推送 SAP | — | 旧侧走 `QadTracking`/DopApi,非 APP 直连。**旧 DOP 中该写入已被注释,链路实为关闭**(见 §7-A3),本轮不做 | — | 已结论:不做 |
 
 ---

+ 3 - 1
doc/plan/租户隔离彻底修正执行任务书.md

@@ -1,6 +1,8 @@
 # 租户隔离彻底修正执行任务书
 
-> **状态**:待执行
+> **状态**:**进行中** — 批次 0 / 1 / 2 / 4 主体已随提交 `0da9ce11b`(server `1.0.327`,2026-08-11)落地;**批次 3 残余与 §9 存量清理尚未完成**,已剥离为 [租户隔离收口与造数前置任务书](./租户隔离收口与造数前置任务书.md)。
+> **剩余工作请执行收口任务书,不要重头再执行本书**——本书多数条目已完成,重复执行有回归风险。尤其注意:S1–S4 / S5–S7 各 `SyncStagingAsync` 调用点残留的 `tenantId: 0` 是**修好之后的无害兜底**(`MdpStagingWriter` 已改为优先读源行 `tenant_id`),**不得再去「修」它们**。
+> **进度核查时间**:2026-08-11(逐项对照当前工作树代码,非凭注释推断)
 > **创建时间**:2026-08-10
 > **依据**:[租户隔离全量审计报告-20260810](./租户隔离全量审计报告-20260810.md)
 > **替代关系**:本任务书**已完整包含**并**取代** [MDP贴源层租户映射缺失修复任务书](./MDP贴源层租户映射缺失修复任务书.md)(该文内容对应本书 **§6 批次 2**)。执行本书即可,无需再单独执行那一份。

+ 456 - 0
doc/plan/租户隔离收口与造数前置任务书.md

@@ -0,0 +1,456 @@
+# 租户隔离收口与造数前置任务书
+
+> **状态**:待执行
+> **建立时间**:2026-08-11(同日修订一次,见 §0.3)
+> **性质**:可派发任务书。**纯后端**改动 + 一份存量清理 SQL,**不改前端**
+> **执行者**:其它大模型 / 协作者。本文档为**可直接照做**的任务书,所有改动均给出确切文件、行号与前后代码;无需再向负责人提问
+> **上游**:[租户隔离彻底修正执行任务书](./租户隔离彻底修正执行任务书.md)(批次 0/1/2/4 主体已随 `0da9ce11b` / server 1.0.327 落地)
+> **下游**:[UAT 多租户数据方案](./UAT多租户数据方案/README.md) 的 **P0** 前置。本书完成后方可开始造数
+
+---
+
+## 0. 执行须知(先读完这一节再动手)
+
+### 0.1 已落地,不要重做
+
+以下已于 2026-08-11 逐项核实完成。**重复执行有回归风险**:
+
+| 项 | 证据 |
+|---|---|
+| 公共租户解析器 `AidopTenantScope.ResolveOrThrow` 及三服务收敛 | `Infrastructure/AidopTenantScope.cs:18-26` |
+| **贴源层根因**:`MdpStagingWriter` 源行 `tenant_id` 优先,解析不到跳过整行并告警,绝不写 0 | `DataPlatform/Executors/MdpStagingWriter.cs:172-176` |
+| S8 演示数据三件套、Demo 租户常量、租户基线初始化钩子 | `OrderFlowConstants.cs:10-14`、`AidopTenantInitService.cs:32-38`、`SysTenantService.cs:309-319` |
+| 实体默认租户值清除 | `AdoS0QualityEntities.cs` 全文件已无 `1300000000001` |
+| 迁移脚本 `1.0.317` / `1.0.318` | 已在库且已执行(`sys_db_migration_log`) |
+
+**特别注意**:S1–S4 / S5–S7 各 `SyncStagingAsync`、`MdpPullContext` 里残留的 `tenantId: 0` / `TenantId = 0` 是**修好之后的无害兜底**(Writer 优先读源行值)。**不要去「修」它们**,改了反而可能引入回归。
+
+### 0.2 已核实不是缺陷,不要改
+
+| 项 | 为何不是缺陷 |
+|---|---|
+| `ProcurementExecution/S4MdpSyncTransformService.cs:425,475`<br>`Supply/S3MdpSyncTransformService.cs:715`<br>按日期删 DWD 且不带 `tenant_id` | 这三处所在的 `BuildDwdAsync` / S3 DWD 构建**不接受租户参数**,是「一次重建全部租户」的全局转换:DELETE 删掉当日所有租户的行,紧随其后的 INSERT 又从 `dwd_supplier_delivery` 把所有租户的行重建回来(`SELECT d.tenant_id ... WHERE d.stat_date=@StatDate`,无租户过滤)。删除范围与重建范围一致,**自洽**。见 §0.4 的使用约束 |
+| `Manufacturing/ToolingLedgerService.cs:12-14` 不按 `tenant_id` 过滤 | 上游任务书**有意保留**(对齐旧系统全表查询行为)。已在 `UAT多租户数据方案` §8.1 登记为隔离验证的口径限制 |
+| `Production/ExecutableDailyPlanService.cs:264`<br>`MaterialWarehouse/ProductionReceiptMdpSyncService.cs:347,350`<br>形如 `ON act.tenant_id = IFNULL(p.tenant_id, 0)` | 这些是 **JOIN 比较条件**,不是写入值,不产生 `tenant_id=0` 数据。本书 WP-2 只改**写入侧**表达式 |
+| `AidopTenantHelper` 的默认回退实现,及残留的 `if (_userManager.TenantId > 0)` 调用方(`DemandScheduleService`、`PurchaseRequestService`、`DeliveryExceptionService`、`OrderReviewOrchestrationService`、`AidopActionRunLogQueryService`) | 上游任务书批次 3 明确「本批次不改实现」。后果是「无 token 时静默全表」,属接口调用方式问题,**不污染造数结果**。若负责人认为 SaaS 下不可接受,应另开一本 |
+
+### 0.3 修订记录
+
+**2026-08-11 修订**:本书初版列有「A 项:补齐跨租户 DELETE 的租户条件」,依据是「多租户分别跑刷新会互删对方当日 DWD」。**该判断经代码核查后不成立并已撤销**,理由见 §0.2 第一行。原 B/C/D 三项重编号为 **WP-2 / WP-3 / WP-1**。执行者若拿到的是初版,请以本版为准,**不要改那三处 DELETE**。
+
+### 0.4 由 §0.2 派生的使用约束(写给数据方案,不是代码改动)
+
+既然 S3/S4 的 DWD 构建是全局的(一次重建所有租户),那么:
+
+> **DWD / KPI 刷新必须全局跑一次,不得按租户分别调用。**
+
+若按租户 A 调 `RunInboundAsync(tenantId: A)`,贴源拉取会限定在租户 A,但随后的 `BuildDwdAsync` 仍会**重建所有租户**的当日 DWD——此时租户 B 的上游 `dwd_supplier_delivery` 若尚未刷新,B 的当日 DWD 会按不完整的上游被重建。
+
+**正确做法**:所有租户的贴源与标准层都就位后,**全局跑一次** DWD/KPI 构建。此约束已同步写入 `UAT多租户数据方案` 的 P6 阶段。
+
+---
+
+## 1. 三个工作包
+
+| 包 | 内容 | 性质 | 为何阻断造数 |
+|---|---|---|---|
+| **WP-1** | 齐套刷新「先删后建、失败即空」+ 存储过程 30 秒超时 | **数据破坏 + 功能不可用** | S3 齐套看板刷新无法完成,造数后无法验收 |
+| **WP-2** | 写入侧租户表达式的 `, 0)` / `IFNULL(..., 0)` 零兜底 | 写入侧租户丢失 | 新造数据经转换层仍会落 `tenant_id=0`,登录租户查不到 |
+| **WP-3** | 存量 `tenant_id=0` / `1300000000001` 残渣清理 | 脏数据 | 与新数据混在一起,无法判断造数是否成功 |
+
+**执行顺序:WP-1 → WP-2 → WP-3。** 三包各自独立提交、独立验收。WP-3 必须最后,因为要等 WP-2 稳定运行、确认不再产生新的零值行,否则清理完下一轮同步又会长出来。
+
+---
+
+## 2. WP-1:齐套刷新数据破坏
+
+### 2.1 实测事实
+
+调用 `POST /api/Supply/work-order-material-readiness/refresh`(AIDOP 租户 `797403760988229`),连续两次均返回:
+
+```json
+{"enabled":false,"message":"执行存储过程 pr_MES_Report_DayWorkOrdDetailShortage_New 失败: The Command Timeout expired before the operation completed."}
+```
+
+耗时稳定 ~31 秒(默认 30 秒命令超时)。`dwd_material_readiness` 行数变化:
+
+| 时点 | 租户 `797403760988229` | `tenant_id=0` |
+|---|---|---|
+| 触发前 | 20872 | 11448 |
+| 触发后 | **0** | 11448 |
+
+排除并发争抢:触发时 `information_schema.PROCESSLIST` 仅 1 个活跃查询,存储过程本身就需要 >30 秒。
+
+**这不是新引入的缺陷,是既有实现**:任何用户在界面上点一次「刷新」都会得到同样结果——看板被清空且无法重建。
+
+### 2.2 根因:Step 1 的全表删除是多余的
+
+当前实现(`Supply/WorkOrderMaterialReadinessService.cs`):
+
+```132:142:server/Plugins/Admin.NET.Plugin.AiDOP/Supply/WorkOrderMaterialReadinessService.cs
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM dwd_material_readiness WHERE tenant_id = @TenantId",
+            tenantPar);
+        try
+        {
+            await _db.Ado.ExecuteCommandAsync("CALL pr_MES_Report_DayWorkOrdDetailShortage_New('')");
+        }
+        catch (Exception ex)
+        {
+            return new { enabled = false, message = $"执行存储过程 pr_MES_Report_DayWorkOrdDetailShortage_New 失败: {ex.Message}" };
+        }
+```
+
+关键在于**后面的 Step 2 + Step 3 已经是一组幂等操作**,不需要前置全表删除:
+
+- **Step 2**(同文件 148-156 行):`DELETE FROM dwd_material_readiness WHERE tenant_id = @TenantId AND work_order NOT IN (SELECT DISTINCT workord FROM WorkOrdTemp ...)` —— 只删「已不在 WorkOrdTemp 中」的过期工单,按租户限定。
+- **Step 3**(同文件 159-207 行):`INSERT ... ON DUPLICATE KEY UPDATE` —— 对当前工单做 upsert。
+
+「删过期 + upsert 当前」本身就等价于一次完整重建。Step 1 那句全表删除是历史冗余(它的注释写的是「清理 WorkOrdTemp 后执行存储过程」,与语句实际删除的表并不对应),且正是它把「失败即清空」这个破坏性引入的。
+
+### 2.3 改法(两处,照抄即可)
+
+#### 改动 1:删除 Step 1 的全表删除
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/Supply/WorkOrderMaterialReadinessService.cs`
+
+**删除**第 131–134 行(注释 + 三行语句):
+
+```csharp
+        // Step 1: 清理 WorkOrdTemp 后执行存储过程填充最新数据
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM dwd_material_readiness WHERE tenant_id = @TenantId",
+            tenantPar);
+```
+
+**替换为**:
+
+```csharp
+        // Step 1: 执行存储过程填充 WorkOrdTemp。
+        // 不在此处删除 dwd_material_readiness:Step 2 删过期工单 + Step 3 upsert 已等价于完整重建,
+        // 且前置全表删除会在存储过程失败时把看板清空且无法重建(2026-08-11 实测丢失 20872 行)。
+```
+
+改动后 `tenantPar` 在该位置不再被使用,但**它在 Step 2/3 仍在用**(148-156、159-207 行),因此**不要删除 `tenantPar` 的声明**(第 129 行)。
+
+#### 改动 2:为存储过程调用单独放宽超时
+
+同文件,把存储过程调用改为带独立超时。SqlSugar 用 `_db.Ado.CommandTimeOut`(单位:秒)控制,改完必须还原,避免影响同一 scope 内后续语句。
+
+**仓库内已有先例,照其写法即可**:
+
+- 全局默认值 30 秒定义在 `server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs:155`(`db.Ado.CommandTimeOut = 30;`),与实测 ~31 秒超时吻合
+- 「保存原值 → 设置 → `finally` 还原」的标准写法见 `SqlSugarSetup.cs:484-493`
+- 同类局部放宽还可参考 `MaterialWarehouse/S5MdpSyncTransformService.cs:535`、`SmartOps/KpiSqlReadOnlyExecutor.cs:105`
+
+**原**(第 135–142 行):
+
+```csharp
+        try
+        {
+            await _db.Ado.ExecuteCommandAsync("CALL pr_MES_Report_DayWorkOrdDetailShortage_New('')");
+        }
+        catch (Exception ex)
+        {
+            return new { enabled = false, message = $"执行存储过程 pr_MES_Report_DayWorkOrdDetailShortage_New 失败: {ex.Message}" };
+        }
+```
+
+**改为**:
+
+```csharp
+        // 该存储过程实测需 30 秒以上(2026-08-11),单独放宽超时;仅覆盖本次调用,随后还原。
+        var prevTimeout = _db.Ado.CommandTimeOut;
+        try
+        {
+            _db.Ado.CommandTimeOut = 300;
+            await _db.Ado.ExecuteCommandAsync("CALL pr_MES_Report_DayWorkOrdDetailShortage_New('')");
+        }
+        catch (Exception ex)
+        {
+            return new { enabled = false, message = $"执行存储过程 pr_MES_Report_DayWorkOrdDetailShortage_New 失败: {ex.Message}" };
+        }
+        finally
+        {
+            _db.Ado.CommandTimeOut = prevTimeout;
+        }
+```
+
+**不要**全局放宽命令超时(不要改 `Database.json`)。
+
+### 2.4 不做的事
+
+- **不优化 `pr_MES_Report_DayWorkOrdDetailShortage_New` 的执行计划。** 它慢很可能与 `S2排程性能优化方案` 同源(热点表缺二级索引导致全表扫描),但改动 1 已保证「失败不破坏数据」,慢一点可接受。**只在本书 §5 建档,不在本包内优化。**
+- **不改 Step 2 / Step 3 / Step 4 的任何 SQL。**
+
+### 2.5 WP-1 验收
+
+- [ ] 临时把 `CommandTimeOut = 300` 改成 `1` 造一次失败,接口返回失败信息,而 `dwd_material_readiness` 行数**保持不变**(改前该场景会清零)
+- [ ] 还原为 300 后正常调用一次,返回 `enabled` 非 false,且 AIDOP 租户行数恢复到万级:
+
+```sql
+SELECT SUM(tenant_id=797403760988229) AS aidop_rows, SUM(tenant_id=0) AS zero_rows
+FROM dwd_material_readiness;
+```
+
+判据:`aidop_rows` 恢复到 2 万左右;`zero_rows` 仍为 11448(存量残渣归 WP-3,本包不动)。
+
+- [ ] S3「工单物料齐套上线看板」页面能查到数据
+- [ ] 全仓检索确认无同类模式:`rg "DELETE FROM dwd_" server/` 后逐条确认,**没有**「删除在前、外部依赖调用在后且失败即 return」的组合
+
+---
+
+## 3. WP-2:清除写入侧租户表达式的零兜底
+
+### 3.1 问题
+
+`MdpStagingWriter` 堵住贴源层之后,**转换层是 `tenant_id=0` 的唯一剩余产生源**。共两类写法。
+
+#### 第一类:共享方法 `MdpJsonSql`(三个方法全部以 `, 0)` 结尾)
+
+```48:58:server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpJsonSql.cs
+    /// <summary>stg 行租户:物理列 NULLIF 去 0 → raw_data → 0(未知)。</summary>
+    public static string TenantFromStg(string alias) =>
+        $"COALESCE(NULLIF({alias}.tenant_id, 0), {RawTenantCase(alias)}, 0)";
+
+    /// <summary>stg 行租户:物理列/json → 上下文参数 → 0。</summary>
+    public static string TenantFromStg(string alias, string ctxParam) =>
+        $"COALESCE(NULLIF({alias}.tenant_id, 0), NULLIF({ctxParam}, 0), {RawTenantCase(alias)}, 0)";
+
+    /// <summary>stg 行租户:JSON 列表达式 + 物理列 + raw_data → 0。</summary>
+    public static string TenantFromStgJsonCol(string alias, string jsonColExpr) =>
+        $"COALESCE(NULLIF({jsonColExpr}, 0), NULLIF({alias}.tenant_id, 0), {RawTenantCase(alias)}, 0)";
+```
+
+#### 第二类:内联零兜底(不经 `MdpJsonSql`)
+
+| 文件 | 行 | 当前写法 |
+|---|---|---|
+| `Order/S1MdpSyncTransformService.cs` | 710-713 | `COALESCE(NULLIF(e.tenant_id,0), NULLIF(h.tenant_id,0), <json case>, 0)` |
+| `Order/S1MdpSyncTransformService.cs` | 788 / 846 / 897 | `COALESCE(NULLIF(d.tenant_id,0), …, 0)` |
+| `Order/S1MdpSyncTransformService.cs` | 941 | `COALESCE(NULLIF(d.tenant_id,0), NULLIF(r.tenant_id,0), NULLIF(so.tenant_id,0), …, 0)` |
+| `Supply/ProcessOutsourceOrderService.cs` | 360 / 412 / 454 | `COALESCE(NULLIF(m.tenant_id, 0), 0)` |
+| `MaterialWarehouse/OutsourceIssueMdpSyncService.cs` | 298 / 346 | `IFNULL({Int(alias,"tenant_id")}, IFNULL({alias}.tenant_id, 0))` |
+| `MaterialWarehouse/ProductionReturnMdpSyncService.cs` | 327 / 391 | 同上 |
+| `MaterialWarehouse/ProductionReceiptMdpSyncService.cs` | 309 | `IFNULL(n.tenant_id, 0)` |
+
+### 3.2 改法第一步:改 `MdpJsonSql`
+
+**文件**:`server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpJsonSql.cs`
+
+三个方法去掉末尾 `, 0`,并**新增一个配对的 WHERE 守卫方法**:
+
+```csharp
+    /// <summary>stg 行租户:物理列 NULLIF 去 0 → raw_data;解析不到返回 NULL。
+    /// 调用方必须在 WHERE 里用 <see cref="TenantGuard"/> 跳过该行,禁止用 0 兜底。</summary>
+    public static string TenantFromStg(string alias) =>
+        $"COALESCE(NULLIF({alias}.tenant_id, 0), {RawTenantCase(alias)})";
+
+    /// <summary>stg 行租户:物理列/json → 上下文参数;解析不到返回 NULL。</summary>
+    public static string TenantFromStg(string alias, string ctxParam) =>
+        $"COALESCE(NULLIF({alias}.tenant_id, 0), NULLIF({ctxParam}, 0), {RawTenantCase(alias)})";
+
+    /// <summary>stg 行租户:JSON 列表达式 + 物理列 + raw_data;解析不到返回 NULL。</summary>
+    public static string TenantFromStgJsonCol(string alias, string jsonColExpr) =>
+        $"COALESCE(NULLIF({jsonColExpr}, 0), NULLIF({alias}.tenant_id, 0), {RawTenantCase(alias)})";
+
+    /// <summary>WHERE 守卫:与上面三个方法配对使用,跳过租户解析不到的行。
+    /// 传入的必须是同一个租户表达式字符串,否则守卫与取值不一致。</summary>
+    public static string TenantGuard(string tenantExpr) => $"({tenantExpr}) IS NOT NULL";
+```
+
+### 3.3 改法第二步:16 个调用点逐个加守卫
+
+改完 §3.2 后,`tenant_id`(`NOT NULL` 列)会因插入 `NULL` 而报错,因此**每个调用点都必须同步加跳过条件**。这不是可选项。
+
+**统一改法模板**:把内联的表达式先提取为局部变量,再在 SELECT 与 WHERE 两处复用。
+
+改前(以 `PurchaseReceiptMdpSyncService.cs:310` 为例):
+
+```csharp
+                {MdpJsonSql.TenantFromStg("p")}, 1, IFNULL(NULLIF(p.source_system,''), 'AIDOP'),
+```
+
+改后:
+
+```csharp
+        // 方法内先提取(放在拼 SQL 之前)
+        var pTenant = MdpJsonSql.TenantFromStg("p");
+        // SELECT 里用 pTenant
+        //     {pTenant}, 1, IFNULL(NULLIF(p.source_system,''), 'AIDOP'),
+        // WHERE 末尾追加
+        //     AND {MdpJsonSql.TenantGuard(pTenant)}
+```
+
+**完整调用点清单(16 处,6 个文件)**。每一处都要「提取局部变量 + SELECT 复用 + WHERE 加守卫」:
+
+| # | 文件 | 行 | 方法 | 别名 |
+|---|---|---|---|---|
+| 1 | `MaterialWarehouse/PurchaseReceiptMdpSyncService.cs` | 310 | `TenantFromStg` | `p` |
+| 2 | `MaterialWarehouse/ProductionIssueMdpSyncService.cs` | 391 | `TenantFromStgJsonCol` | `m` |
+| 3 | `Manufacturing/ReportWorkMdpSyncService.cs` | 69 | `TenantFromStg(,"@tid")` | `s` |
+| 4 | `Manufacturing/IpqcInspectionMdpSyncService.cs` | 547 | `TenantFromStg` | `m` |
+| 5 | `Manufacturing/IpqcInspectionMdpSyncService.cs` | 652 | `TenantFromStg` | `d` |
+| 6 | `FinishedWarehouse/FqcMdpSyncService.cs` | 413 | `TenantFromStgJsonCol` | `t` |
+| 7 | `FinishedWarehouse/FqcMdpSyncService.cs` | 456 | `TenantFromStgJsonCol` | `a` |
+| 8 | `FinishedWarehouse/FqcMdpSyncService.cs` | 500 | `TenantFromStgJsonCol` | `d` |
+| 9–16 | `DataPlatform/T8BaseInboundMdpSyncService.cs` | 138 / 154 / 170 / 184 / 200 / 213 / 227 / 241 | `TenantFromStg(,"@tid")` | `s` |
+
+第 4、5 处(`IpqcInspectionMdpSyncService`)**已经有** `WHERE ... IS NOT NULL` 形式的跳过条件,核对后若已等价则只需确认,不必重复添加。
+
+### 3.4 改法第三步:内联零兜底逐个改
+
+按 §3.1 第二类表格逐处改,原则一致:**去掉零兜底,让表达式可为 NULL,并在 WHERE 加 `IS NOT NULL` 跳过**。
+
+- `S1MdpSyncTransformService.cs` 五处:把 `COALESCE(...)` 最后那个 `, 0` 去掉,在对应 INSERT…SELECT 的 WHERE 末尾追加 `AND COALESCE(...) IS NOT NULL`(表达式与 SELECT 中完全一致)。建议同样提取为局部变量避免两处不同步。
+- `ProcessOutsourceOrderService.cs` 三处:`COALESCE(NULLIF(m.tenant_id, 0), 0)` → `NULLIF(m.tenant_id, 0)`,WHERE 加 `AND NULLIF(m.tenant_id, 0) IS NOT NULL`。
+- `OutsourceIssueMdpSyncService.cs` / `ProductionReturnMdpSyncService.cs` 四处:`IFNULL({Int(alias,"tenant_id")}, IFNULL({alias}.tenant_id, 0))` → `COALESCE({Int(alias,"tenant_id")}, NULLIF({alias}.tenant_id, 0))`,WHERE 加对应 `IS NOT NULL`。
+- `ProductionReceiptMdpSyncService.cs:309`:`IFNULL(n.tenant_id, 0)` → `NULLIF(n.tenant_id, 0)`,WHERE 加 `AND NULLIF(n.tenant_id, 0) IS NOT NULL`。
+
+**跳过的行要留痕**:每个改动点所在方法,在返回结果里已有行数统计的就够了;若原本没有,**不必**新增日志——跳过行会体现为「同步行数少于源行数」,配合 §3.6 的验收 SQL 足以发现。
+
+### 3.5 顺带:去掉 IPQC 建表语句的默认租户
+
+`Manufacturing/IpqcInspectionMdpSyncService.cs` 的 `EnsureTablesAsync` 在第 **320** 与 **414** 行的 `CREATE TABLE` 里仍带 `DEFAULT 1300000000001`,新环境部署会重新引入默认租户污染。
+
+两件都要做:
+
+1. 改建表语句,去掉这两处 `DEFAULT 1300000000001`。
+2. 出迁移脚本 **`1.0.326.sql`** 改现有表(幂等,用 `information_schema` 判存在):对这两张表的 `tenant_id` 列执行 `ALTER TABLE ... MODIFY COLUMN tenant_id BIGINT NOT NULL`(去掉 DEFAULT,保留 NOT NULL)。配 `1.0.326.verify.sql` 校验 `COLUMN_DEFAULT IS NULL`。
+
+迁移脚本硬约束见 §4。
+
+### 3.6 WP-2 验收
+
+改完后按模块各跑一轮同步,然后:
+
+```sql
+SELECT 'mdp_std_purchase_order' t, tenant_id, COUNT(*) c FROM mdp_std_purchase_order GROUP BY tenant_id
+UNION ALL SELECT 'mdp_std_purchase_receipt', tenant_id, COUNT(*) FROM mdp_std_purchase_receipt GROUP BY tenant_id
+UNION ALL SELECT 'mdp_std_production_issue', tenant_id, COUNT(*) FROM mdp_std_production_issue GROUP BY tenant_id
+UNION ALL SELECT 'mdp_std_s6_report', tenant_id, COUNT(*) FROM mdp_std_s6_report GROUP BY tenant_id
+UNION ALL SELECT 'mdp_std_production_return', tenant_id, COUNT(*) FROM mdp_std_production_return GROUP BY tenant_id
+UNION ALL SELECT 'mdp_std_outsource_issue', tenant_id, COUNT(*) FROM mdp_std_outsource_issue GROUP BY tenant_id
+ORDER BY 1,2;
+```
+
+判据:
+
+- [ ] **同步后新增的行中不得出现 `tenant_id = 0`**(存量零值行归 WP-3,此处只看增量:对比同步前后各租户行数增量)
+- [ ] 同步任务全部返回成功,`mdp_transform_run_log` 无新增失败记录
+- [ ] `rg "tenant_id, 0\)" server/Plugins/Admin.NET.Plugin.AiDOP/` 剩余命中项,逐条确认均属 §0.2 白名单(JOIN 比较条件)
+- [ ] 上游任务书 §12 回归矩阵跑一遍,**任何列表页行数不得减少**
+
+---
+
+## 4. 迁移脚本硬约束
+
+本书涉及的 SQL 脚本遵循 `AutoVersionUpdate` 约束,违反会**阻断后端启动**:
+
+- 文件必须 **LF 行尾**(CRLF 会导致解析失败)
+- 脚本号必须 **≤ `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 的 `<Version>`**;当前为 `1.0.327`,已使用到 `1.0.325`
+- **不得使用 `DELIMITER`**
+- 已执行过的脚本**内容一个字都不能改**
+- 每个 `x.y.z.sql` 配 `x.y.z.verify.sql`
+- 在 `.csproj` 中补对应的 `None Update` 项,确保脚本复制到输出目录
+
+脚本号分配:
+
+| 脚本号 | 内容 | 前置条件 |
+|---|---|---|
+| `1.0.326` | WP-2 §3.5:去掉 IPQC 两表 `tenant_id` 的 `DEFAULT 1300000000001` | 无(≤ 当前 1.0.327) |
+| `1.0.328` | WP-3 存量清理(含备份表创建) | **须先把 `.csproj` 三处版本号 patch 到 `1.0.328`** |
+
+**版本号规则**:本书为**纯后端**改动,只递增 `Admin.NET.Web.Entry.csproj` 的 `<Version>` / `<AssemblyVersion>` / `<FileVersion>`(三处同号,patch +1),**不要动 `Web/package.json`**。三个工作包各自独立提交,各自升一次号。
+
+---
+
+## 5. WP-3:存量残渣清理
+
+### 5.1 现状基线(2026-08-11 实测)
+
+| 表 | 正确租户 `797403760988229` | `tenant_id=0` | 默认租户 `1300000000001` |
+|---|---|---|---|
+| `dwd_material_readiness` | 0(WP-1 修复后恢复) | 11448 | — |
+| `dwd_s4_purchase_execution` | 20157 | 1475 | 60 |
+| `dwd_supplier_risk` | 11391 | 1542 | 71 |
+| `dwd_material_shortage` | 0 | 146 | — |
+| `mdp_std_purchase_order` | 434 | 101 | 1 |
+| `mdp_std_purchase_receipt` | 11 | 3 | — |
+| `mdp_std_production_issue` | 0 | 2 | — |
+| `mdp_std_s6_report` | 0 | 2 | — |
+
+**为什么这些行清不掉**:各刷新的删除语句按「租户 + 日期」或「租户 + 工单」限定,零值行落在任何真实租户的范围之外,永远不会被覆盖,只能显式清理。
+
+**`dwd_material_shortage` 看似 100% 污染的解释**:其 `max(stat_date)` 为 2026-08-11 00:00(+08),即旧程序集产物;「旧零值行清不掉」与「修复后新行尚未写入」叠加所致,**不是写入侧仍在持续产生污染**。
+
+### 5.2 清理步骤
+
+**前置**:WP-1、WP-2 已合并并稳定运行至少一轮完整同步,且 §3.6 验收通过(增量无零值行)。否则清完又会长出来。
+
+`1.0.328.sql` 按以下顺序编写,**每步都要能单独核对影响行数**:
+
+1. **建备份表**(幂等,`CREATE TABLE IF NOT EXISTS ... AS SELECT`):对下列每张表备份 `tenant_id IN (0, 1300000000001)` 的行到 `{表名}_bak_20260811`。
+2. **逐表删除**污染行,每条 `DELETE` 单独一句,便于逐条比对影响行数与 §5.1 基线。
+3. 只清 **派生层**:`dwd_*`、`mdp_std_*`、`mdp_stg_*`。**业务表一律不动。**
+
+涉及表(与 §5.1 一致):`dwd_material_readiness`、`dwd_material_shortage`、`dwd_s4_purchase_execution`、`dwd_supplier_risk`、`mdp_std_purchase_order`、`mdp_std_purchase_receipt`、`mdp_std_production_issue`、`mdp_std_s6_report`。
+
+执行前另需跑一遍**全量扫描**,确认没有 §5.1 未列出的派生表也带污染:
+
+```sql
+SELECT table_name FROM information_schema.tables
+WHERE table_schema = 'aidopdev' AND table_type = 'BASE TABLE'
+  AND (table_name LIKE 'dwd\_%' OR table_name LIKE 'mdp\_std\_%' OR table_name LIKE 'mdp\_stg\_%')
+ORDER BY table_name;
+```
+
+对结果逐表 `SELECT tenant_id, COUNT(*) ... GROUP BY tenant_id`,把新发现的污染表补进清理脚本与备份清单。
+
+> 注意:该库有 560 张表,`information_schema` 上的重查询容易超时,逐表分批执行,不要写成一个大 UNION。
+
+### 5.3 `1300000000001` 的额外确认
+
+`tenant_id = 1300000000001` 是**框架默认租户**(`SqlSugarConst.DefaultTenantId`,即「系统默认」平台租户),不是业务租户。§5.1 中 `dwd_s4_purchase_execution` 60 行、`dwd_supplier_risk` 71 行、`mdp_std_purchase_order` 1 行属此类。
+
+**清理前必须确认**:没有任何业务确实归属该租户。确认方式——用该租户登录,逐个打开 S3/S4 相关页面,若全部为空则可安全清理。**若发现有业务在用,停止清理并上报负责人**,不要自行判断归属。
+
+### 5.4 顺带:`ado_s8_exception` 的野值租户
+
+`ado_s8_exception` 共 **42 行** `tenant_id = 1` —— 既不是任何真实租户,也不是框架默认租户 `1300000000001`。后果是 **S8 异常列表在所有租户下均为空**。
+
+属上游任务书批次 4 遗留。**归属需负责人指定**(迁到 AIDOP 租户 `797403760988229` 还是 Demo 租户 `797403760988230`),这是本书唯一需要外部输入的点。**在拿到指定之前,不要动这 42 行**,也不要为了「让页面有数据」随意选一个租户。
+
+### 5.5 WP-3 验收
+
+- [ ] 备份表已建,行数与 §5.1 各表污染行数一致
+- [ ] 清理后 §5.1 涉及表的 `tenant_id IN (0, 1300000000001)` 全部为 0 行
+- [ ] 再跑一轮全量同步,**不再出现**零值行(这一条是对 WP-2 的最终验证)
+- [ ] 上游任务书 §12 回归矩阵跑一遍,任何列表页行数不得减少
+- [ ] `ado_s8_exception` 按负责人指定的租户迁移完成,S8 异常列表在该租户下有数据
+
+---
+
+## 6. 明确不做
+
+- **不改那三处按日期删 DWD 的语句**(见 §0.2 与 §0.3)。
+- **不修 `ToolingLedgerService` 的跨租户读取**(上游有意保留)。
+- **不优化 `pr_MES_Report_DayWorkOrdDetailShortage_New`**(见 §2.4)。
+- **不动 165**,本书全部改动在 Ai-DOP 侧。
+- **不清理业务表**,WP-3 只清派生层。
+- **不改前端**。
+- **不改 `Database.json` 的全局命令超时**(见 §2.3 改动 2)。
+- **不收敛 `AidopTenantHelper` 的默认回退实现**及那 5 个残留调用方(见 §0.2 末行)。
+
+---
+
+## 7. 完成判据(造数放行条件)
+
+全部满足后,`UAT多租户数据方案` 的 P1 可以开始:
+
+- [ ] WP-1:存储过程失败时 `dwd_material_readiness` 行数不变;正常刷新后 AIDOP 租户行数恢复万级
+- [ ] WP-2:`MdpJsonSql` 三方法无零兜底、16 个调用点与 7 处内联写法全部加了跳过条件;同步后增量行无 `tenant_id=0`
+- [ ] WP-3:§5.1 表中 `tenant_id IN (0, 1300000000001)` 全部清零,且重刷后不再出现
+- [ ] `ado_s8_exception` 42 行归属已定并迁移
+- [ ] 上游任务书 §12 回归矩阵跑一遍,任何列表页行数不得减少
+- [ ] 三个工作包各自独立提交,后端版本号各 patch +1

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.332</AssemblyVersion>
-    <FileVersion>1.0.332</FileVersion>
-    <Version>1.0.332</Version>
+    <AssemblyVersion>1.0.336</AssemblyVersion>
+    <FileVersion>1.0.336</FileVersion>
+    <Version>1.0.336</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -328,6 +328,12 @@
     <None Update="UpdateScripts\1.0.325.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.335.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.336.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 25 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.335.sql

@@ -0,0 +1,25 @@
+-- S5 IQC:补建 IQC 执行器贴源表(与 mdp_stg_fqc_pull 同构)
+-- 背景:mdp_entity.S5_IQC_INSPBILL_SQLSERVER 已启用并指向 mdp_stg_iqc_pull,
+--       但该贴源表缺失,热关注回读写贴源时整批 poll 抛异常。
+-- 可重复执行。
+
+CREATE TABLE IF NOT EXISTS `mdp_stg_iqc_pull` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `tenant_id` bigint NOT NULL,
+  `source_system` varchar(50) DEFAULT NULL,
+  `source_table` varchar(200) DEFAULT NULL,
+  `source_row_id` varchar(200) DEFAULT NULL,
+  `source_biz_key` varchar(300) DEFAULT NULL,
+  `raw_data` json DEFAULT NULL,
+  `sync_batch_id` varchar(100) DEFAULT NULL,
+  `sync_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `process_status` varchar(20) NOT NULL DEFAULT 'PENDING',
+  `process_message` varchar(500) DEFAULT NULL,
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_source_key` (`source_system`,`source_table`,`source_biz_key`),
+  KEY `idx_batch` (`sync_batch_id`),
+  KEY `idx_src` (`source_table`,`source_row_id`),
+  KEY `idx_tenant` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S5 IQC 执行器贴源层(报检/检验单共表,source_table 区分)';

+ 8 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.336.sql

@@ -0,0 +1,8 @@
+-- S5 IQC P1:修正冷链实体 biz_key_expr(原 Domain,BillNbr 与表结构不符)
+-- 可重复执行;不启用冷链实体本身(页面走热关注回读业务表)
+
+UPDATE mdp_entity
+SET biz_key_expr = 'FBILLNO',
+    update_time = NOW()
+WHERE entity_code = 'S5_IQC_INSPBILL_SQLSERVER'
+  AND (biz_key_expr IS NULL OR biz_key_expr <> 'FBILLNO');

+ 192 - 13
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Executors/MdpDbPushExecutor.cs

@@ -1,5 +1,6 @@
 using System.Collections;
 using System.Globalization;
+using System.Text;
 using System.Text.Json;
 using System.Text.RegularExpressions;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
@@ -8,7 +9,8 @@ using SqlSugar;
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
 
 /// <summary>
-/// 字段级 DB 回写:按 payload_json 对 165 SQL Server 执行 INSERT/UPDATE/UPSERT。
+/// 字段级 DB 回写:按 payload_json 对 165 SQL Server 执行 INSERT/UPDATE/UPSERT;
+/// 另支持受限 <c>op=PROC</c>(过程名白名单,见 <see cref="AllowedProcs"/>)。
 /// 只保证「不越表、不无条件更新、不注入」;列级白名单由 WP4 业务编排保证。
 /// </summary>
 public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
@@ -23,13 +25,26 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
         "NbrMaster", "NbrDetail",
         "PeriodSequenceDet", "srm_polist_ds",
         "scm_shd", "scm_shdzb", "scm_shdshph", "MissedPrint",
+        "MissedPrintTransHist",
+        "qms_qcp_insappnentry", "qms_qcp_inspbill",
         "ScheduleResultOpMaster", "rf_serialnumber",
         // WP7 基础数据 + 任务(DOP 独占)
         "LocationMaster", "LocationShelfMaster", "DepartmentMaster",
         "EmployeeMaster", "EmpWorkDutyMaster", "LinePrinter",
         "MobileTask",
         // 单号计数器(NbrSequenceService 直写为主;Outbox 备用)
-        "NbrDayInfo"
+        "NbrDayInfo",
+        // S5 IQC QcCheck:resolve 读不良品库位(只读,不写)
+        "PurOrdControl"
+    };
+
+    /// <summary>
+    /// op=PROC 过程名白名单。本轮仅放 <c>pr_WMS_BPM_SaveInvUpShelf</c>。
+    /// 依据:doc/plan/S5-IQC合格上架过账(QcCheck)执行任务书.md §3 Q9;白名单变更须同级评审。
+    /// </summary>
+    private static readonly HashSet<string> AllowedProcs = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "pr_WMS_BPM_SaveInvUpShelf"
     };
 
     private readonly MdpSourceScopeFactory _scopeFactory;
@@ -57,21 +72,32 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
             return MdpPushResult.Fail($"payload 解析失败:{ex.Message}");
         }
 
-        ValidateTable(payload.Table);
-        ValidateColumns(payload.Keys, "keys");
-        ValidateColumns(payload.Insert, "insert");
-        ValidateColumns(payload.Update, "update");
-        ValidateColumns(payload.Expect, "expect");
-        ValidateResolves(payload.Resolves);
-
         var op = (payload.Op ?? "").Trim().ToUpperInvariant();
         try
         {
+            if (op == "PROC")
+            {
+                ValidateProc(payload.Proc);
+                ValidateColumns(payload.Args, "args");
+                ValidateResolves(payload.Resolves);
+                if (RejectArrayValues(payload.Args, "args") is { } argsErr)
+                    return MdpPushResult.Fail(argsErr);
+            }
+            else
+            {
+                ValidateTable(payload.Table);
+                ValidateColumns(payload.Keys, "keys");
+                ValidateColumns(payload.Insert, "insert");
+                ValidateColumns(payload.Update, "update");
+                ValidateColumns(payload.Expect, "expect");
+                ValidateResolves(payload.Resolves);
+            }
+
             var db = await _scopeFactory.GetScopeAsync(source.SourceCode, ct);
 
             if (payload.Resolves.Count > 0)
             {
-                var resolveErr = await ApplyResolvesAsync(db, payload, ct);
+                var resolveErr = await ApplyResolvesAsync(db, payload, op, ct);
                 if (resolveErr != null) return MdpPushResult.Fail(resolveErr);
             }
 
@@ -80,6 +106,7 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
                 "INSERT" => await ExecInsertAsync(db, payload, ct),
                 "UPDATE" => await ExecUpdateAsync(db, payload, item.ActionCode, ct),
                 "UPSERT" => await ExecUpsertAsync(db, payload, item.ActionCode, ct),
+                "PROC" => await ExecProcAsync(db, payload, ct),
                 _ => MdpPushResult.Fail($"不支持的 op:{payload.Op}")
             };
         }
@@ -196,6 +223,120 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
         }
     }
 
+    /// <summary>
+    /// 调 165 白名单存储过程。关键:部分过程在 <c>@IsProcCall=1</c> 时只开事务不提交,
+    /// 必须由调用方包事务并按 <c>@@TRANCOUNT</c> 收口(见 QcCheck 任务书 §4.2)。
+    /// </summary>
+    private static async Task<MdpPushResult> ExecProcAsync(ISqlSugarClient db, DbPushPayload p, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(p.Proc))
+            return MdpPushResult.Fail("PROC 必须提供 proc");
+        if (p.Args.Count == 0)
+            return MdpPushResult.Fail("PROC 必须提供 args");
+
+        var outputName = string.IsNullOrWhiteSpace(p.Output) ? "ReturnMsg" : p.Output!;
+        if (!ColNameRegex.IsMatch(outputName))
+            return MdpPushResult.Fail($"非法 OUTPUT 参数名:{outputName}");
+
+        // QcCheck:SP 在 @Shelf 仅库位号(@InvShelf='')时,「已是此货架」校验不命中,会重复过账。
+        // 入队前按标签当前库位预检,已在目标库位则幂等跳过。
+        var preSkip = await TrySkipQcCheckIfAlreadyThereAsync(db, p, ct);
+        if (preSkip != null) return preSkip;
+
+        var successNeedle = string.IsNullOrWhiteSpace(p.Success) ? "成功" : p.Success!;
+        var argKeys = p.Args.Keys.ToList();
+        var assignParts = new List<string>(argKeys.Count + 1);
+        var pars = new List<SugarParameter>(argKeys.Count);
+        for (var i = 0; i < argKeys.Count; i++)
+        {
+            var key = argKeys[i];
+            assignParts.Add($"@{key}=@p{i}");
+            pars.Add(new SugarParameter($"@p{i}", CoerceDbValue(p.Args[key]) ?? DBNull.Value));
+        }
+        assignParts.Add($"@{outputName}=@ret OUTPUT");
+
+        var sql = new StringBuilder();
+        sql.AppendLine("SET XACT_ABORT ON;");
+        sql.AppendLine("DECLARE @ret varchar(max) = '';");
+        sql.AppendLine("BEGIN TRAN;");
+        sql.Append("EXEC [").Append(p.Proc).Append("] ");
+        sql.Append(string.Join(", ", assignParts)).AppendLine(";");
+        sql.AppendLine("IF CHARINDEX(N'成功', ISNULL(@ret,'')) = 0");
+        sql.AppendLine("    WHILE @@TRANCOUNT > 0 ROLLBACK;");
+        sql.AppendLine("ELSE");
+        sql.AppendLine("    WHILE @@TRANCOUNT > 0 COMMIT;");
+        sql.AppendLine("SELECT @ret AS ReturnMsg;");
+
+        // 事务收口固定看「成功」(与 SP 文案「保存成功!」对齐);业务成功/幂等再按 payload.success 判定。
+        var dt = await db.Ado.GetDataTableAsync(sql.ToString(), pars.ToArray());
+        var retMsg = "";
+        if (dt is { Rows.Count: > 0 } && dt.Rows[0][0] != DBNull.Value && dt.Rows[0][0] != null)
+            retMsg = Convert.ToString(dt.Rows[0][0]) ?? "";
+
+        var response = JsonSerializer.Serialize(new { returnMsg = retMsg });
+
+        if (p.Idempotent.Count > 0
+            && p.Idempotent.Any(s => !string.IsNullOrEmpty(s)
+                                      && retMsg.Contains(s, StringComparison.Ordinal)))
+        {
+            return MdpPushResult.Skip(response);
+        }
+
+        if (retMsg.Contains(successNeedle, StringComparison.Ordinal))
+            return MdpPushResult.Ok(1, response);
+
+        return MdpPushResult.Fail(
+            string.IsNullOrWhiteSpace(retMsg) ? "PROC 未返回成功消息" : retMsg,
+            response);
+    }
+
+    /// <summary>
+    /// QcCheck 幂等预检:MissedPrint.Location 已等于目标库位时跳过,避免同库位重复 iss-tr-ins。
+    /// </summary>
+    private static async Task<MdpPushResult?> TrySkipQcCheckIfAlreadyThereAsync(
+        ISqlSugarClient db, DbPushPayload p, CancellationToken ct)
+    {
+        if (!string.Equals(p.Proc, "pr_WMS_BPM_SaveInvUpShelf", StringComparison.OrdinalIgnoreCase))
+            return null;
+        if (!p.Args.TryGetValue("TransCode", out var tc) || tc == null)
+            return null;
+        if (!string.Equals(Convert.ToString(tc), "QcCheck", StringComparison.OrdinalIgnoreCase))
+            return null;
+        if (!p.Args.TryGetValue("Details", out var detailsObj) || detailsObj == null)
+            return null;
+        if (!p.Args.TryGetValue("Shelf", out var shelfObj) || shelfObj == null)
+            return null;
+
+        var details = Convert.ToString(detailsObj, CultureInfo.InvariantCulture)?.Trim() ?? "";
+        if (string.IsNullOrEmpty(details) || details.Contains('|', StringComparison.Ordinal)
+            || details.Contains(',', StringComparison.Ordinal))
+            return null; // 批量细节不做预检,交给 SP
+
+        var shelf = Convert.ToString(shelfObj, CultureInfo.InvariantCulture)?.Trim() ?? "";
+        if (string.IsNullOrEmpty(shelf)) return null;
+        var splitIdx = shelf.IndexOf(':');
+        var locationTo = splitIdx > 0 ? shelf[..splitIdx] : shelf;
+
+        var curLocObj = await db.Ado.GetScalarAsync(
+            "SELECT TOP 1 [Location] FROM [MissedPrint] WHERE [RecID]=@id",
+            new SugarParameter("@id", details));
+        var curLoc = curLocObj == null || curLocObj == DBNull.Value
+            ? ""
+            : Convert.ToString(curLocObj, CultureInfo.InvariantCulture)?.Trim() ?? "";
+        if (string.IsNullOrEmpty(curLoc)) return null;
+
+        if (string.Equals(curLoc, locationTo, StringComparison.OrdinalIgnoreCase))
+        {
+            return MdpPushResult.Skip(JsonSerializer.Serialize(new
+            {
+                returnMsg = "已是此货架(执行器预检)",
+                location = curLoc
+            }));
+        }
+
+        return null;
+    }
+
     private static async Task<bool> ExistsAsync(
         ISqlSugarClient db, string table, Dictionary<string, object?> keys, CancellationToken ct)
     {
@@ -253,7 +394,7 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
     /// 上游行还没到位时返回失败,交给 dispatcher 退避重试,等主表推成功后自然收敛。
     /// </summary>
     private static async Task<string?> ApplyResolvesAsync(
-        ISqlSugarClient db, DbPushPayload p, CancellationToken ct)
+        ISqlSugarClient db, DbPushPayload p, string op, CancellationToken ct)
     {
         foreach (var r in p.Resolves)
         {
@@ -263,7 +404,11 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
             var val = await db.Ado.GetScalarAsync(sql, pars);
             if (val == null || val == DBNull.Value)
                 return $"resolve 未命中:{r.Table}.{r.Column}(目标列 {r.TargetColumn}),上游行未就绪";
-            p.Insert[r.TargetColumn] = val;
+            // PROC:resolve 写入 args;其余写入 insert(既有语义)
+            if (op == "PROC")
+                p.Args[r.TargetColumn] = Convert.ToString(val, CultureInfo.InvariantCulture);
+            else
+                p.Insert[r.TargetColumn] = val;
         }
         return null;
     }
@@ -293,6 +438,14 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
             throw new InvalidOperationException($"表不在回写白名单:{table}");
     }
 
+    private static void ValidateProc(string proc)
+    {
+        if (string.IsNullOrWhiteSpace(proc) || !ColNameRegex.IsMatch(proc))
+            throw new InvalidOperationException($"非法过程名:{proc}");
+        if (!AllowedProcs.Contains(proc))
+            throw new InvalidOperationException($"过程不在回写白名单:{proc}");
+    }
+
     private static void ValidateColumns(Dictionary<string, object?> cols, string part)
     {
         foreach (var c in cols.Keys)
@@ -310,15 +463,36 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
         {
             Op = root.TryGetProperty("op", out var op) ? op.GetString() ?? "" : "",
             Table = root.TryGetProperty("table", out var t) ? t.GetString() ?? "" : "",
+            Proc = root.TryGetProperty("proc", out var proc) ? proc.GetString() ?? "" : "",
             Keys = ReadDict(root, "keys"),
             Insert = ReadDict(root, "insert"),
             Update = ReadDict(root, "update"),
             Expect = ReadDict(root, "expect"),
-            Resolves = ReadResolves(root)
+            Args = ReadDict(root, "args"),
+            Resolves = ReadResolves(root),
+            Output = root.TryGetProperty("output", out var output) ? output.GetString() ?? "" : "",
+            Success = root.TryGetProperty("success", out var success) ? success.GetString() ?? "" : "",
+            Idempotent = ReadStringList(root, "idempotent")
         };
         return p;
     }
 
+    private static List<string> ReadStringList(JsonElement root, string name)
+    {
+        var list = new List<string>();
+        if (!root.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Array)
+            return list;
+        foreach (var item in el.EnumerateArray())
+        {
+            if (item.ValueKind == JsonValueKind.String)
+            {
+                var s = item.GetString();
+                if (!string.IsNullOrEmpty(s)) list.Add(s);
+            }
+        }
+        return list;
+    }
+
     private static List<ResolveSpec> ReadResolves(JsonElement root)
     {
         var list = new List<ResolveSpec>();
@@ -366,11 +540,16 @@ public sealed class MdpDbPushExecutor : IMdpTargetPushExecutor, ITransient
     {
         public string Op { get; set; } = "";
         public string Table { get; set; } = "";
+        public string Proc { get; set; } = "";
         public Dictionary<string, object?> Keys { get; set; } = new();
         public Dictionary<string, object?> Insert { get; set; } = new();
         public Dictionary<string, object?> Update { get; set; } = new();
         public Dictionary<string, object?> Expect { get; set; } = new();
+        public Dictionary<string, object?> Args { get; set; } = new();
         public List<ResolveSpec> Resolves { get; set; } = new();
+        public string Output { get; set; } = "";
+        public string Success { get; set; } = "";
+        public List<string> Idempotent { get; set; } = new();
     }
 
     private sealed class ResolveSpec

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

@@ -144,13 +144,34 @@ public sealed class MdpHotWatchService : ITransient
         if (tenantId <= 0)
             tenantId = await ResolveLocalPurOrdTenantAsync(purOrd, domain, ct);
 
-        await EnrollAsync(
-            "PUR_ORDER",
-            purOrd,
-            domain,
-            new[] { "PurOrdMaster", "PurOrdDetail", "MissedPrint" },
-            tenantId,
-            ct: ct);
+        // S5 IQC:在采购收货热关注上叠加报检/检验单三表,供申请列表与任务列表出数
+        var tables = new[]
+        {
+            "PurOrdMaster", "PurOrdDetail", "MissedPrint",
+            "qms_qcp_inspecapplyn", "qms_qcp_insappnentry", "qms_qcp_inspbill"
+        };
+
+        var existing = await _db.Queryable<AdoMdpHotWatch>()
+            .Where(x => x.BizType == "PUR_ORDER" && x.BizKey == purOrd && x.Status == 0)
+            .FirstAsync(ct);
+        if (existing != null)
+        {
+            // 已登记的关注行补齐 qms 三表(旧行只有采购三表)
+            var cur = ParseTables(existing.WatchTables);
+            var needUpdate = tables.Any(t => !cur.Contains(t, StringComparer.OrdinalIgnoreCase));
+            if (needUpdate)
+            {
+                existing.WatchTables = JsonSerializer.Serialize(tables);
+                existing.UpdateTime = DateTime.Now;
+                if (tenantId > 0 && existing.TenantId <= 0) existing.TenantId = tenantId;
+                await _db.Updateable(existing)
+                    .UpdateColumns(x => new { x.WatchTables, x.TenantId, x.UpdateTime })
+                    .ExecuteCommandAsync(ct);
+            }
+            return;
+        }
+
+        await EnrollAsync("PUR_ORDER", purOrd, domain, tables, tenantId, ct: ct);
     }
 
     private static bool TryParseWoIdem(string? idem, out string domain, out string workOrd)
@@ -210,15 +231,16 @@ public sealed class MdpHotWatchService : ITransient
             foreach (var watch in group.Take(MaxKeysPerBatch))
             {
                 var tables = ParseTables(watch.WatchTables);
-                var sb = new StringBuilder();
+                // 序无关哈希:QueryByBizKey 多段无 ORDER BY,行序抖动会导致指纹每轮变化并无谓重放 UPSERT
+                var hashParts = new List<string>();
                 foreach (var table in tables)
                 {
                     var rows = await QueryByBizKeyAsync(remote, table, watch.Domain, watch.BizType, watch.BizKey, ct);
                     foreach (var row in rows)
-                        sb.Append(JsonSerializer.Serialize(row));
+                        hashParts.Add(JsonSerializer.Serialize(row));
                 }
-
-                var hash = Sha256(sb.ToString());
+                hashParts.Sort(StringComparer.Ordinal);
+                var hash = Sha256(string.Concat(hashParts));
                 watch.LastPollTime = now;
                 watch.UpdateTime = now;
 
@@ -275,11 +297,17 @@ public sealed class MdpHotWatchService : ITransient
                     {
                         tableRows.TryGetValue("PurOrdDetail", out var purDetails);
                         tableRows.TryGetValue("MissedPrint", out var barcodes);
+                        tableRows.TryGetValue("qms_qcp_inspecapplyn", out var iqcApplies);
+                        tableRows.TryGetValue("qms_qcp_insappnentry", out var iqcEntries);
+                        tableRows.TryGetValue("qms_qcp_inspbill", out var iqcBills);
                         var effectiveTenantId = watch.TenantId;
                         if (effectiveTenantId <= 0)
                             effectiveTenantId = await ResolveLocalPurOrdTenantAsync(watch.BizKey, watch.Domain, ct);
                         await ApplyPurchaseReceiptAsync(
                             effectiveTenantId, watch.Domain, watch.BizKey, purDetails, barcodes, ct);
+                        await ApplyIqcAsync(
+                            effectiveTenantId, watch.Domain, watch.BizKey,
+                            iqcApplies, iqcEntries, iqcBills, ct);
                     }
 
                     if (await ShouldTerminateAsync(remote, watch, ct))
@@ -360,6 +388,40 @@ public sealed class MdpHotWatchService : ITransient
                 sql = "SELECT * FROM MobileTask WHERE TaskID=@k";
                 pars = new[] { new SugarParameter("@k", bizKey) };
                 break;
+            // S5 IQC:按采购单 → 收货单 → 报检分录/主表/检验单(业务键窄查)
+            case "QMS_QCP_INSAPPNENTRY":
+                sql = """
+                    SELECT TOP 200 e.*
+                    FROM qms_qcp_insappnentry e WITH (NOLOCK)
+                    INNER JOIN PurOrdRctDetail d WITH (NOLOCK)
+                      ON d.Receiver = e.FSRCORDERNUM AND d.ItemNum = e.FMATERIALCFG
+                    WHERE d.Domain = @d AND d.OrdNbr = @k
+                    """;
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
+            case "QMS_QCP_INSPECAPPLYN":
+                sql = """
+                    SELECT DISTINCT TOP 200 a.*
+                    FROM qms_qcp_inspecapplyn a WITH (NOLOCK)
+                    INNER JOIN qms_qcp_insappnentry e WITH (NOLOCK) ON a.id = e.glid
+                    INNER JOIN PurOrdRctDetail d WITH (NOLOCK)
+                      ON d.Receiver = e.FSRCORDERNUM AND d.ItemNum = e.FMATERIALCFG
+                    WHERE d.Domain = @d AND d.OrdNbr = @k
+                    """;
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
+            case "QMS_QCP_INSPBILL":
+                sql = """
+                    SELECT DISTINCT TOP 200 b.*
+                    FROM qms_qcp_inspbill b WITH (NOLOCK)
+                    INNER JOIN qms_qcp_inspecapplyn a WITH (NOLOCK) ON a.FBILLNO = b.lydjbh
+                    INNER JOIN qms_qcp_insappnentry e WITH (NOLOCK) ON a.id = e.glid
+                    INNER JOIN PurOrdRctDetail d WITH (NOLOCK)
+                      ON d.Receiver = e.FSRCORDERNUM AND d.ItemNum = e.FMATERIALCFG
+                    WHERE d.Domain = @d AND d.OrdNbr = @k
+                    """;
+                pars = new[] { new SugarParameter("@d", domain), new SugarParameter("@k", bizKey) };
+                break;
             default:
                 // MissedPrint 等:降级按 Domain + OrdNbr(可能扫表,见 WP8 E1)
                 if (string.Equals(table, "MissedPrint", StringComparison.OrdinalIgnoreCase))
@@ -578,6 +640,201 @@ public sealed class MdpHotWatchService : ITransient
             workOrd, domain, grouped.Count, periodDets.Count);
     }
 
+    /// <summary>
+    /// 把 165 的报检单/分录/检验单 UPSERT 进本库业务表(S5 IQC P1)。
+    /// 同名列 1:1 + 补 tenant_id;主键沿用 165 的 id(本库 id 非自增)。
+    /// </summary>
+    private async Task ApplyIqcAsync(
+        long tenantId,
+        string domain,
+        string purOrd,
+        List<Dictionary<string, object>>? applies,
+        List<Dictionary<string, object>>? entries,
+        List<Dictionary<string, object>>? bills,
+        CancellationToken ct)
+    {
+        if (tenantId <= 0)
+        {
+            _logger.LogWarning(
+                "[MdpHotWatch] ApplyIqc 跳过:无法解析租户 purOrd={PurOrd} domain={Domain}", purOrd, domain);
+            return;
+        }
+
+        var nApply = 0;
+        var nEntry = 0;
+        var nBill = 0;
+
+        if (applies != null)
+        {
+            foreach (var row in applies)
+            {
+                if (!TryGetInt64(row, "id", out var id) || id <= 0) continue;
+                var billNo = GetString(row, "FBILLNO");
+                if (string.IsNullOrWhiteSpace(billNo)) continue;
+
+                nApply += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO qms_qcp_inspecapplyn (
+                        id, tenant_id, FBILLNO, FBILLTYPE, FBIZTYPE, FAPPLYTIME, FCOMMENT
+                    ) VALUES (
+                        @Id, @TenantId, @FBILLNO, @FBILLTYPE, @FBIZTYPE, @FAPPLYTIME, @FCOMMENT
+                    )
+                    ON DUPLICATE KEY UPDATE
+                        FBILLNO = VALUES(FBILLNO),
+                        FBILLTYPE = VALUES(FBILLTYPE),
+                        FBIZTYPE = VALUES(FBIZTYPE),
+                        FAPPLYTIME = VALUES(FAPPLYTIME),
+                        FCOMMENT = VALUES(FCOMMENT),
+                        tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)
+                    """,
+                    new SugarParameter("@Id", id),
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@FBILLNO", billNo),
+                    new SugarParameter("@FBILLTYPE", GetString(row, "FBILLTYPE") ?? (object)DBNull.Value),
+                    new SugarParameter("@FBIZTYPE", GetString(row, "FBIZTYPE") ?? (object)DBNull.Value),
+                    new SugarParameter("@FAPPLYTIME", GetDateTime(row, "FAPPLYTIME") ?? (object)DBNull.Value),
+                    new SugarParameter("@FCOMMENT", GetString(row, "FCOMMENT") ?? (object)DBNull.Value));
+            }
+        }
+
+        if (entries != null)
+        {
+            foreach (var row in entries)
+            {
+                if (!TryGetInt64(row, "id", out var id) || id <= 0) continue;
+
+                // jyfzr/yxj 为 Ai-DOP 独占故不回读;状态只许前进,165 的旧值/空值不得打回
+                nEntry += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO qms_qcp_insappnentry (
+                        id, tenant_id, glid, FSEQ, FMATERIALCFG, wlmc, ggxh, FLOTNUMBER,
+                        FSRCORDERTYPE, FSRCORDERNUM, FAPPLYQTY, FINSPECTSTATUS,
+                        jykssj, jywcsj, FWAREHOUSEID, FLOCATIONID, FSUPPLIER, gysbm, gysmc, shdh
+                    ) VALUES (
+                        @Id, @TenantId, @Glid, @FSEQ, @FMATERIALCFG, @Wlmc, @Ggxh, @FLOTNUMBER,
+                        @FSRCORDERTYPE, @FSRCORDERNUM, @FAPPLYQTY, @FINSPECTSTATUS,
+                        @Jykssj, @Jywcsj, @FWAREHOUSEID, @FLOCATIONID, @FSUPPLIER, @Gysbm, @Gysmc, @Shdh
+                    )
+                    ON DUPLICATE KEY UPDATE
+                        glid = VALUES(glid),
+                        FSEQ = VALUES(FSEQ),
+                        FMATERIALCFG = VALUES(FMATERIALCFG),
+                        wlmc = VALUES(wlmc),
+                        ggxh = VALUES(ggxh),
+                        FLOTNUMBER = VALUES(FLOTNUMBER),
+                        FSRCORDERTYPE = VALUES(FSRCORDERTYPE),
+                        FSRCORDERNUM = VALUES(FSRCORDERNUM),
+                        FAPPLYQTY = VALUES(FAPPLYQTY),
+                        FINSPECTSTATUS = IF(
+                            FIELD(VALUES(FINSPECTSTATUS), '未检验', '检验中', '检验完成') > 0
+                            AND FIELD(VALUES(FINSPECTSTATUS), '未检验', '检验中', '检验完成')
+                                >= FIELD(FINSPECTSTATUS, '未检验', '检验中', '检验完成'),
+                            VALUES(FINSPECTSTATUS), FINSPECTSTATUS),
+                        jykssj = IF(IFNULL(VALUES(jykssj),'')='', jykssj, VALUES(jykssj)),
+                        jywcsj = IF(IFNULL(VALUES(jywcsj),'')='', jywcsj, VALUES(jywcsj)),
+                        FWAREHOUSEID = VALUES(FWAREHOUSEID),
+                        FLOCATIONID = VALUES(FLOCATIONID),
+                        FSUPPLIER = VALUES(FSUPPLIER),
+                        gysbm = VALUES(gysbm),
+                        gysmc = VALUES(gysmc),
+                        shdh = VALUES(shdh),
+                        tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)
+                    """,
+                    new SugarParameter("@Id", id),
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@Glid", GetInt64OrNull(row, "glid") ?? (object)DBNull.Value),
+                    new SugarParameter("@FSEQ", GetIntOrNull(row, "FSEQ") ?? (object)DBNull.Value),
+                    new SugarParameter("@FMATERIALCFG", GetString(row, "FMATERIALCFG") ?? (object)DBNull.Value),
+                    new SugarParameter("@Wlmc", GetString(row, "wlmc") ?? (object)DBNull.Value),
+                    new SugarParameter("@Ggxh", GetString(row, "ggxh") ?? (object)DBNull.Value),
+                    new SugarParameter("@FLOTNUMBER", GetString(row, "FLOTNUMBER") ?? (object)DBNull.Value),
+                    new SugarParameter("@FSRCORDERTYPE", GetString(row, "FSRCORDERTYPE") ?? (object)DBNull.Value),
+                    new SugarParameter("@FSRCORDERNUM", GetString(row, "FSRCORDERNUM") ?? (object)DBNull.Value),
+                    new SugarParameter("@FAPPLYQTY", GetDecimal(row, "FAPPLYQTY")),
+                    new SugarParameter("@FINSPECTSTATUS", GetString(row, "FINSPECTSTATUS") ?? (object)DBNull.Value),
+                    new SugarParameter("@Jykssj", GetString(row, "jykssj") ?? (object)DBNull.Value),
+                    new SugarParameter("@Jywcsj", GetString(row, "jywcsj") ?? (object)DBNull.Value),
+                    new SugarParameter("@FWAREHOUSEID", GetString(row, "FWAREHOUSEID") ?? (object)DBNull.Value),
+                    new SugarParameter("@FLOCATIONID", GetString(row, "FLOCATIONID") ?? (object)DBNull.Value),
+                    new SugarParameter("@FSUPPLIER", GetString(row, "FSUPPLIER") ?? (object)DBNull.Value),
+                    new SugarParameter("@Gysbm", GetString(row, "gysbm") ?? (object)DBNull.Value),
+                    new SugarParameter("@Gysmc", GetString(row, "gysmc") ?? (object)DBNull.Value),
+                    new SugarParameter("@Shdh", GetString(row, "shdh") ?? (object)DBNull.Value));
+            }
+        }
+
+        if (bills != null)
+        {
+            foreach (var row in bills)
+            {
+                if (!TryGetInt64(row, "id", out var id) || id <= 0) continue;
+                var billNo = GetString(row, "FBILLNO");
+                if (string.IsNullOrWhiteSpace(billNo)) continue;
+
+                nBill += await _db.Ado.ExecuteCommandAsync(
+                    """
+                    INSERT INTO qms_qcp_inspbill (
+                        id, tenant_id, FBILLNO, lydjbh, hid, FMATERIALCFG, wlmc, ggxh, gysmc, pch,
+                        FRINSQTY, jysl, dhsl, bhgsl, pd, clfs, FBILLSTATUS, jyr, FINSPECTORID,
+                        FINSPESTARTDATE, FCREATETIME
+                    ) VALUES (
+                        @Id, @TenantId, @FBILLNO, @Lydjbh, @Hid, @FMATERIALCFG, @Wlmc, @Ggxh, @Gysmc, @Pch,
+                        @FRINSQTY, @Jysl, @Dhsl, @Bhgsl, @Pd, @Clfs, @FBILLSTATUS, @Jyr, @FINSPECTORID,
+                        @FINSPESTARTDATE, @FCREATETIME
+                    )
+                    ON DUPLICATE KEY UPDATE
+                        FBILLNO = VALUES(FBILLNO),
+                        lydjbh = VALUES(lydjbh),
+                        hid = VALUES(hid),
+                        FMATERIALCFG = VALUES(FMATERIALCFG),
+                        wlmc = VALUES(wlmc),
+                        ggxh = VALUES(ggxh),
+                        gysmc = VALUES(gysmc),
+                        pch = VALUES(pch),
+                        FRINSQTY = VALUES(FRINSQTY),
+                        jysl = VALUES(jysl),
+                        dhsl = IF(VALUES(dhsl) IS NULL, dhsl, VALUES(dhsl)),
+                        bhgsl = IF(VALUES(bhgsl) IS NULL, bhgsl, VALUES(bhgsl)),
+                        pd = IF(VALUES(pd) IS NULL, pd, VALUES(pd)),
+                        clfs = IF(VALUES(clfs) IS NULL, clfs, VALUES(clfs)),
+                        FBILLSTATUS = IF(IFNULL(VALUES(FBILLSTATUS),'')='', FBILLSTATUS, VALUES(FBILLSTATUS)),
+                        jyr = IF(IFNULL(VALUES(jyr),'')='', jyr, VALUES(jyr)),
+                        FINSPECTORID = IF(IFNULL(VALUES(FINSPECTORID),'')='', FINSPECTORID, VALUES(FINSPECTORID)),
+                        FINSPESTARTDATE = IF(VALUES(FINSPESTARTDATE) IS NULL, FINSPESTARTDATE, VALUES(FINSPESTARTDATE)),
+                        tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)
+                    """,
+                    new SugarParameter("@Id", id),
+                    new SugarParameter("@TenantId", tenantId),
+                    new SugarParameter("@FBILLNO", billNo),
+                    new SugarParameter("@Lydjbh", GetString(row, "lydjbh") ?? (object)DBNull.Value),
+                    new SugarParameter("@Hid", GetInt64OrNull(row, "hid") ?? (object)DBNull.Value),
+                    new SugarParameter("@FMATERIALCFG", GetString(row, "FMATERIALCFG") ?? (object)DBNull.Value),
+                    new SugarParameter("@Wlmc", GetString(row, "wlmc") ?? (object)DBNull.Value),
+                    new SugarParameter("@Ggxh", GetString(row, "ggxh") ?? (object)DBNull.Value),
+                    new SugarParameter("@Gysmc", GetString(row, "gysmc") ?? (object)DBNull.Value),
+                    new SugarParameter("@Pch", GetString(row, "pch") ?? (object)DBNull.Value),
+                    new SugarParameter("@FRINSQTY", GetDecimal(row, "FRINSQTY")),
+                    new SugarParameter("@Jysl", GetDecimal(row, "jysl")),
+                    new SugarParameter("@Dhsl", GetDecimal(row, "dhsl")),
+                    new SugarParameter("@Bhgsl", GetDecimal(row, "bhgsl")),
+                    new SugarParameter("@Pd", GetInt64OrNull(row, "pd") ?? (object)DBNull.Value),
+                    new SugarParameter("@Clfs", GetInt64OrNull(row, "clfs") ?? (object)DBNull.Value),
+                    new SugarParameter("@FBILLSTATUS", GetString(row, "FBILLSTATUS") ?? (object)DBNull.Value),
+                    new SugarParameter("@Jyr", GetString(row, "jyr") ?? (object)DBNull.Value),
+                    new SugarParameter("@FINSPECTORID", GetString(row, "FINSPECTORID") ?? (object)DBNull.Value),
+                    new SugarParameter("@FINSPESTARTDATE", GetDateTime(row, "FINSPESTARTDATE") ?? (object)DBNull.Value),
+                    new SugarParameter("@FCREATETIME", GetDateTime(row, "FCREATETIME") ?? (object)DBNull.Value));
+            }
+        }
+
+        if (nApply + nEntry + nBill > 0)
+        {
+            _logger.LogInformation(
+                "[MdpHotWatch] applied IQC purOrd={PurOrd} domain={Domain} apply={A} entry={E} bill={B}",
+                purOrd, domain, nApply, nEntry, nBill);
+        }
+    }
+
     /// <summary>
     /// 把 165 的采购收货结果回读本库:明细收货数、箱码状态,并据箱码推进送货单状态。
     ///
@@ -803,6 +1060,41 @@ public sealed class MdpHotWatchService : ITransient
         }
     }
 
+    private static bool TryGetInt64(Dictionary<string, object> row, string key, out long value)
+    {
+        value = 0;
+        if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return false;
+        try
+        {
+            value = Convert.ToInt64(raw);
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private static long? GetInt64OrNull(Dictionary<string, object> row, string key)
+    {
+        if (!TryGetInt64(row, key, out var v)) return null;
+        return v;
+    }
+
+    private static int? GetIntOrNull(Dictionary<string, object> row, string key)
+    {
+        if (!TryGetInt(row, key, out var v)) return null;
+        return v;
+    }
+
+    private static DateTime? GetDateTime(Dictionary<string, object> row, string key)
+    {
+        if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return null;
+        if (raw is DateTime dt) return dt;
+        if (DateTime.TryParse(Convert.ToString(raw), out var parsed)) return parsed;
+        return null;
+    }
+
     private static decimal GetDecimal(Dictionary<string, object> row, string key)
     {
         if (!row.TryGetValue(key, out var raw) || raw == null || raw is DBNull) return 0m;
@@ -849,7 +1141,7 @@ public sealed class MdpHotWatchService : ITransient
             if (string.Equals(status?.ToString()?.Trim(), "C", StringComparison.OrdinalIgnoreCase))
                 return true;
 
-            // 箱码全部离开待收即收货结束;标签尚未推达(total=0)时不能终结
+            // S5 IQC:收货完成后还要等报检分录全部「检验完成」才终结(否则 IQC 回读窗口被掐断)
             var total = Convert.ToInt32(await remote.Ado.GetScalarAsync(
                 "SELECT COUNT(*) FROM MissedPrint WHERE Domain=@d AND OrdNbr=@k",
                 new SugarParameter("@d", watch.Domain),
@@ -860,7 +1152,34 @@ public sealed class MdpHotWatchService : ITransient
                     "SELECT COUNT(*) FROM MissedPrint WHERE Domain=@d AND OrdNbr=@k AND IsNull(Status,'')='U'",
                     new SugarParameter("@d", watch.Domain),
                     new SugarParameter("@k", watch.BizKey)) ?? 0);
-                if (pending == 0) return true;
+                if (pending == 0)
+                {
+                    var entryTotal = Convert.ToInt32(await remote.Ado.GetScalarAsync(
+                        """
+                        SELECT COUNT(*)
+                        FROM qms_qcp_insappnentry e WITH (NOLOCK)
+                        INNER JOIN PurOrdRctDetail d WITH (NOLOCK)
+                          ON d.Receiver = e.FSRCORDERNUM AND d.ItemNum = e.FMATERIALCFG
+                        WHERE d.Domain = @d AND d.OrdNbr = @k
+                        """,
+                        new SugarParameter("@d", watch.Domain),
+                        new SugarParameter("@k", watch.BizKey)) ?? 0);
+                    if (entryTotal <= 0)
+                        return false; // 已收完但报检尚未出现:继续等
+
+                    var entryPending = Convert.ToInt32(await remote.Ado.GetScalarAsync(
+                        """
+                        SELECT COUNT(*)
+                        FROM qms_qcp_insappnentry e WITH (NOLOCK)
+                        INNER JOIN PurOrdRctDetail d WITH (NOLOCK)
+                          ON d.Receiver = e.FSRCORDERNUM AND d.ItemNum = e.FMATERIALCFG
+                        WHERE d.Domain = @d AND d.OrdNbr = @k
+                          AND IsNull(e.FINSPECTSTATUS,'') <> N'检验完成'
+                        """,
+                        new SugarParameter("@d", watch.Domain),
+                        new SugarParameter("@k", watch.BizKey)) ?? 0);
+                    if (entryPending == 0) return true;
+                }
             }
         }
         // 兜底:超过 7 天强制终结

+ 68 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpOutboxAdminService.cs

@@ -13,11 +13,19 @@ public class MdpOutboxAdminService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly MdpOutboxWakeSignal _wake;
-
-    public MdpOutboxAdminService(ISqlSugarClient db, MdpOutboxWakeSignal wake)
+    private readonly MdpDbPushExecutor _dbPush;
+    private readonly MdpApiPushExecutor _apiPush;
+
+    public MdpOutboxAdminService(
+        ISqlSugarClient db,
+        MdpOutboxWakeSignal wake,
+        MdpDbPushExecutor dbPush,
+        MdpApiPushExecutor apiPush)
     {
         _db = db;
         _wake = wake;
+        _dbPush = dbPush;
+        _apiPush = apiPush;
     }
 
     public sealed class PageInput
@@ -163,4 +171,62 @@ public class MdpOutboxAdminService : IDynamicApiController, ITransient
 
         return new { reset };
     }
+
+    /// <summary>
+    /// 本进程内立刻执行一条 Outbox(不经集群/其它实例抢消费),用于 PROC 等新能力验收。
+    /// </summary>
+    [DisplayName("出站回写本机立即执行")]
+    [HttpPost("push-now/{id:long}")]
+    public async Task<object> PushNow(long id, CancellationToken ct = default)
+    {
+        var item = await _db.Queryable<MdpOutbox>().InSingleAsync(id);
+        if (item == null)
+            throw Oops.Oh($"outbox id={id} 不存在");
+
+        var source = await _db.Queryable<MdpSource>()
+            .Where(x => x.SourceCode == item.TargetSourceCode && x.Status == 1)
+            .FirstAsync(ct);
+        if (source == null)
+            throw Oops.Oh($"目标源 {item.TargetSourceCode} 未启用");
+
+        IMdpTargetPushExecutor executor = string.Equals(source.SourceType, "DB", StringComparison.OrdinalIgnoreCase)
+            ? _dbPush
+            : _apiPush;
+
+        var result = await executor.PushAsync(source, item, ct);
+        var now = DateTime.Now;
+        if (result.Success)
+        {
+            await _db.Updateable<MdpOutbox>()
+                .SetColumns(x => x.Status == 1)
+                .SetColumns(x => x.ErrorMsg == null)
+                .SetColumns(x => x.LastErrorCode == null)
+                .SetColumns(x => x.NextRetryTime == null)
+                .SetColumns(x => x.ResponseJson == result.ResponseJson)
+                .SetColumns(x => x.UpdateTime == now)
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync(ct);
+        }
+        else
+        {
+            await _db.Updateable<MdpOutbox>()
+                .SetColumns(x => x.Status == 2)
+                .SetColumns(x => x.ErrorMsg == (result.ErrorMessage ?? "push failed"))
+                .SetColumns(x => x.ResponseJson == result.ResponseJson)
+                .SetColumns(x => x.NextRetryTime == null)
+                .SetColumns(x => x.UpdateTime == now)
+                .Where(x => x.Id == id)
+                .ExecuteCommandAsync(ct);
+        }
+
+        return new
+        {
+            id,
+            success = result.Success,
+            idempotentSkip = result.IdempotentSkip,
+            errorMessage = result.ErrorMessage,
+            responseJson = result.ResponseJson,
+            affectedRows = result.AffectedRows
+        };
+    }
 }

+ 65 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcTaskEntryDto.cs

@@ -0,0 +1,65 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+public class IqcTaskEntryListInput
+{
+    public string? ApplyBillNo { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? LotNo { get; set; }
+    public string? InspectStatus { get; set; }
+    public string? Receiver { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+public class IqcTaskEntryRow
+{
+    public long Id { get; set; }
+    public long? Glid { get; set; }
+    public string? ApplyBillNo { get; set; }
+    public string? BizType { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? MaterialName { get; set; }
+    public string? Spec { get; set; }
+    public string? LotNo { get; set; }
+    public string? Receiver { get; set; }
+    public decimal? ApplyQty { get; set; }
+    public string? InspectStatus { get; set; }
+    public string? OwnerId { get; set; }
+    public string? OwnerName { get; set; }
+    public long? Priority { get; set; }
+    public string? SupplierCode { get; set; }
+    public string? SupplierName { get; set; }
+    public string? ShipmentNo { get; set; }
+    public long? InspBillId { get; set; }
+    public string? InspBillNo { get; set; }
+}
+
+public class IqcTaskEntryIdsInput
+{
+    public List<long> Ids { get; set; } = new();
+}
+
+public class IqcTaskEntryAssignInput
+{
+    public List<long> Ids { get; set; } = new();
+    /// <summary>检验负责人用户 Id(统一存用户 id,见任务书 Q6/U9)</summary>
+    public long InspectorUserId { get; set; }
+}
+
+public class IqcTaskEntryPriorityInput
+{
+    public List<long> Ids { get; set; } = new();
+    public long Priority { get; set; }
+}
+
+public class IqcGenerateInspBillInput
+{
+    /// <summary>报检分录 id(qms_qcp_insappnentry.id)</summary>
+    public long EntryId { get; set; }
+}
+
+public class IqcGenerateInspBillOutput
+{
+    public long InspBillId { get; set; }
+    public string InspBillNo { get; set; } = "";
+}

+ 22 - 74
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs

@@ -1,6 +1,4 @@
-using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity;
-using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
@@ -14,8 +12,9 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// N1_INSPECT 检验员录入 pd/dhsl/bhgsl/clfs → 提交 → N2_SUP_REVIEW 检验主管审核(通过=CLOSED / 退回=回 N1)。
 /// 复用 ApprovalFlow 引擎扩展点(StartFlow/Approve/ReturnToPrev + IFlowBizHandler),不改 ApprovalFlow 核心。
 /// BizType=S5_IQC_INSPBILL,BizId=qms_qcp_inspbill.id,BizCode=FBILLNO。反查用 ApprovalFlowInstance(BizType,BizId),inspbill 不加列。
-/// submit-result 仅写 qms_qcp_inspbill.pd/dhsl/bhgsl/clfs;approve/reject 不写 qms。不写库存、不入库、不做 SQE/仓储通知。
-/// 正常鉴权(非 AllowAnonymous);权限以当前用户 pending task 归属为准。
+/// submit-result 写 qms_qcp_inspbill.pd/dhsl/bhgsl/clfs 并推进 N1→N2;
+/// Q5:提交结果即推 165(IqcWmsResultPushService),主管审核不再碰 165。
+/// approve/reject 不写 qms。不写库存、不入库。
 /// </summary>
 [ApiDescriptionSettings(Order = 310, Description = "来料检验单流程")]
 [Route("api/S5IqcInspBillFlow")]
@@ -28,12 +27,18 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
+    private readonly IqcWmsResultPushService _wmsPush;
 
-    public IqcInspBillFlowService(ISqlSugarClient db, FlowEngineService flowEngine, UserManager userManager)
+    public IqcInspBillFlowService(
+        ISqlSugarClient db,
+        FlowEngineService flowEngine,
+        UserManager userManager,
+        IqcWmsResultPushService wmsPush)
     {
         _db = db;
         _flowEngine = flowEngine;
         _userManager = userManager;
+        _wmsPush = wmsPush;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -132,11 +137,22 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
 
+        // Q5:检验员提交即推 165(对齐旧 DOP submitAfter);失败不阻断主流程
+        try
+        {
+            var tid = ResolveTenantOrThrow();
+            await _wmsPush.TryEnqueueAfterSubmitAsync(input.Id, tid);
+        }
+        catch
+        {
+            // 推送失败不影响提交结果本身
+        }
+
         return await BuildStateAsync(input.Id);
     }
 
     /// <summary>
-    /// 检验主管通过(N2 → 完成)。
+    /// 检验主管通过(N2 → 完成)。仅走 Ai-DOP 内部审批,不再写 165。
     /// </summary>
     [DisplayName("检验主管通过")]
     [HttpPost("supervisor-approve")]
@@ -151,9 +167,6 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
 
-        // 双模式出站:主管通过且判定合格时写入 Outbox(QMS_API 占位源,真实地址配置后由 Job 推送)
-        await TryEnqueueIqcPassOutboxAsync(input.Id);
-
         return await BuildStateAsync(input.Id);
     }
 
@@ -391,69 +404,4 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
         return output;
     }
 
-    /// <summary>
-    /// 合格闭环时写 mdp_outbox(幂等);失败不阻断主流程。
-    /// </summary>
-    private async Task TryEnqueueIqcPassOutboxAsync(long billId)
-    {
-        try
-        {
-            var row = await _db.Ado.SqlQuerySingleAsync<IqcOutboxBillRow>(
-                "SELECT id AS Id, FBILLNO AS BillNo, pd AS Pd, dhsl AS Dhsl, bhgsl AS Bhgsl FROM qms_qcp_inspbill WHERE id=@id LIMIT 1",
-                new List<SugarParameter> { new("@id", billId) });
-            if (row == null || row.Pd != 0) return; // 仅合格回写
-
-            string billNo = string.IsNullOrWhiteSpace(row.BillNo) ? billId.ToString() : row.BillNo!;
-            long tenantId = ResolveTenantOrThrow();
-            var payload = JsonSerializer.Serialize(new
-            {
-                path = "/iqc/result",
-                method = "POST",
-                body = new
-                {
-                    billId,
-                    billNo,
-                    pd = row.Pd,
-                    dhsl = row.Dhsl,
-                    bhgsl = row.Bhgsl,
-                    action = "S5_IQC_RESULT_PUSH"
-                }
-            });
-
-            var exists = await _db.Ado.GetIntAsync(
-                "SELECT COUNT(1) FROM mdp_outbox WHERE tenant_id=@tid AND target_source_code='QMS_API' AND action_code='S5_IQC_RESULT_PUSH' AND idem_key=@idem",
-                new List<SugarParameter>
-                {
-                    new("@tid", tenantId),
-                    new("@idem", billNo)
-                }) > 0;
-            if (exists) return;
-
-            await _db.Insertable(new MdpOutbox
-            {
-                TenantId = tenantId,
-                TargetSourceCode = "QMS_API",
-                ActionCode = "S5_IQC_RESULT_PUSH",
-                IdemKey = billNo,
-                PayloadJson = payload,
-                Status = 0,
-                RetryCount = 0,
-                CreateTime = DateTime.Now,
-                UpdateTime = DateTime.Now
-            }).ExecuteCommandAsync();
-        }
-        catch
-        {
-            // Outbox 写入失败不影响检验闭环
-        }
-    }
-
-    private sealed class IqcOutboxBillRow
-    {
-        public long Id { get; set; }
-        public string? BillNo { get; set; }
-        public int? Pd { get; set; }
-        public decimal? Dhsl { get; set; }
-        public decimal? Bhgsl { get; set; }
-    }
 }

+ 355 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcTaskEntryService.cs

@@ -0,0 +1,355 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 来料检验任务(报检分录)操作:认领 / 调配 / 优先级 / 检验完成 / 生成检验单。
+/// 对齐旧 DOP 任务列表(program 396052925665349)行为,数据落本库 qms_qcp_*。
+/// </summary>
+[ApiDescriptionSettings(Order = 311, Description = "来料检验任务操作")]
+[Route("api/S5IqcTaskEntry")]
+[NonUnify]
+public class IqcTaskEntryService : IDynamicApiController, ITransient
+{
+    private const long MainTenantId = 1300000000001L;
+
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public IqcTaskEntryService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    private long ResolveTenantOrThrow()
+    {
+        var tid = _userManager.TenantId;
+        if (tid <= 0) throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+        if (_userManager.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+        return tid;
+    }
+
+    [DisplayName("来料检验任务列表(报检分录)")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] IqcTaskEntryListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
+        var offset = (page - 1) * pageSize;
+        var tenantId = ResolveTenantOrThrow();
+
+        var where = new List<string> { "e.tenant_id = @TenantId" };
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
+
+        if (!string.IsNullOrWhiteSpace(input.ApplyBillNo))
+        {
+            where.Add("a.FBILLNO LIKE @ApplyBillNo");
+            pars.Add(new SugarParameter("@ApplyBillNo", $"%{input.ApplyBillNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.MaterialCode))
+        {
+            where.Add("e.FMATERIALCFG LIKE @MaterialCode");
+            pars.Add(new SugarParameter("@MaterialCode", $"%{input.MaterialCode.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.LotNo))
+        {
+            where.Add("e.FLOTNUMBER LIKE @LotNo");
+            pars.Add(new SugarParameter("@LotNo", $"%{input.LotNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.InspectStatus))
+        {
+            where.Add("e.FINSPECTSTATUS = @InspectStatus");
+            pars.Add(new SugarParameter("@InspectStatus", input.InspectStatus.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Receiver))
+        {
+            where.Add("e.FSRCORDERNUM LIKE @Receiver");
+            pars.Add(new SugarParameter("@Receiver", $"%{input.Receiver.Trim()}%"));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync(
+            $"""
+             SELECT COUNT(1)
+             FROM qms_qcp_insappnentry e
+             LEFT JOIN qms_qcp_inspecapplyn a ON a.id = e.glid AND a.tenant_id = e.tenant_id
+             WHERE {whereSql}
+             """,
+            pars);
+
+        var list = await _db.Ado.SqlQueryAsync<IqcTaskEntryRow>(
+            $"""
+             SELECT
+                 e.id AS Id,
+                 e.glid AS Glid,
+                 a.FBILLNO AS ApplyBillNo,
+                 a.FBIZTYPE AS BizType,
+                 e.FMATERIALCFG AS MaterialCode,
+                 e.wlmc AS MaterialName,
+                 e.ggxh AS Spec,
+                 e.FLOTNUMBER AS LotNo,
+                 e.FSRCORDERNUM AS Receiver,
+                 e.FAPPLYQTY AS ApplyQty,
+                 e.FINSPECTSTATUS AS InspectStatus,
+                 e.jyfzr AS OwnerId,
+                 CAST(NULL AS CHAR) AS OwnerName,
+                 e.yxj AS Priority,
+                 e.FSUPPLIER AS SupplierCode,
+                 e.gysmc AS SupplierName,
+                 e.shdh AS ShipmentNo,
+                 b.id AS InspBillId,
+                 b.FBILLNO AS InspBillNo
+             FROM qms_qcp_insappnentry e
+             LEFT JOIN qms_qcp_inspecapplyn a ON a.id = e.glid AND a.tenant_id = e.tenant_id
+             LEFT JOIN qms_qcp_inspbill b ON b.hid = e.id AND b.tenant_id = e.tenant_id
+             WHERE {whereSql}
+             ORDER BY IFNULL(e.yxj, 999999) ASC, e.id DESC
+             LIMIT {pageSize} OFFSET {offset}
+             """,
+            pars);
+
+        await FillOwnerNamesAsync(list);
+        return new { total, page, pageSize, list };
+    }
+
+    private async Task FillOwnerNamesAsync(List<IqcTaskEntryRow> list)
+    {
+        var ids = list
+            .Select(x => x.OwnerId)
+            .Where(x => !string.IsNullOrWhiteSpace(x) && long.TryParse(x, out _))
+            .Select(x => long.Parse(x!))
+            .Distinct()
+            .ToList();
+        if (ids.Count == 0) return;
+
+        var users = await _db.Queryable<SysUser>()
+            .ClearFilter()
+            .Where(u => ids.Contains(u.Id))
+            .Select(u => new { u.Id, u.RealName, u.Account })
+            .ToListAsync();
+        var map = users.ToDictionary(u => u.Id.ToString(), u => u.RealName ?? u.Account ?? u.Id.ToString());
+        foreach (var row in list)
+        {
+            if (!string.IsNullOrWhiteSpace(row.OwnerId) && map.TryGetValue(row.OwnerId!, out var name))
+                row.OwnerName = name;
+        }
+    }
+
+    [DisplayName("认领检验任务")]
+    [HttpPost("claim")]
+    public async Task<object> Claim([FromBody] IqcTaskEntryIdsInput input)
+    {
+        var ids = NormalizeIds(input?.Ids);
+        var tenantId = ResolveTenantOrThrow();
+        var owner = _userManager.UserId.ToString();
+        var n = await _db.Ado.ExecuteCommandAsync(
+            $"""
+             UPDATE qms_qcp_insappnentry
+             SET jyfzr = @Owner
+             WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})
+             """,
+            new SugarParameter("@Owner", owner),
+            new SugarParameter("@TenantId", tenantId));
+        return new { updated = n, ownerId = owner };
+    }
+
+    [DisplayName("检验员调配")]
+    [HttpPost("assign")]
+    public async Task<object> Assign([FromBody] IqcTaskEntryAssignInput input)
+    {
+        var ids = NormalizeIds(input?.Ids);
+        if (input == null || input.InspectorUserId <= 0) throw Oops.Oh("请指定检验负责人");
+        var tenantId = ResolveTenantOrThrow();
+        var owner = input.InspectorUserId.ToString();
+        var n = await _db.Ado.ExecuteCommandAsync(
+            $"""
+             UPDATE qms_qcp_insappnentry
+             SET jyfzr = @Owner
+             WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})
+             """,
+            new SugarParameter("@Owner", owner),
+            new SugarParameter("@TenantId", tenantId));
+        return new { updated = n, ownerId = owner };
+    }
+
+    [DisplayName("优先级调整")]
+    [HttpPost("priority")]
+    public async Task<object> AdjustPriority([FromBody] IqcTaskEntryPriorityInput input)
+    {
+        var ids = NormalizeIds(input?.Ids);
+        var tenantId = ResolveTenantOrThrow();
+        var n = await _db.Ado.ExecuteCommandAsync(
+            $"""
+             UPDATE qms_qcp_insappnentry
+             SET yxj = @Priority
+             WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})
+             """,
+            new SugarParameter("@Priority", input!.Priority),
+            new SugarParameter("@TenantId", tenantId));
+        return new { updated = n };
+    }
+
+    [DisplayName("检验完成(手工兜底)")]
+    [HttpPost("complete")]
+    public async Task<object> MarkComplete([FromBody] IqcTaskEntryIdsInput input)
+    {
+        var ids = NormalizeIds(input?.Ids);
+        var tenantId = ResolveTenantOrThrow();
+        var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+        var n = await _db.Ado.ExecuteCommandAsync(
+            $"""
+             UPDATE qms_qcp_insappnentry
+             SET FINSPECTSTATUS = '检验完成', jywcsj = @Now
+             WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})
+             """,
+            new SugarParameter("@Now", now),
+            new SugarParameter("@TenantId", tenantId));
+        return new { updated = n };
+    }
+
+    /// <summary>
+    /// 「来料检验」:校验认领人 + 幂等 → 置检验中 → 本库生成检验单(IQC+yyyyMMdd+4 位)。
+    /// </summary>
+    [DisplayName("生成来料检验单")]
+    [HttpPost("generate-inspbill")]
+    public async Task<IqcGenerateInspBillOutput> GenerateInspBill([FromBody] IqcGenerateInspBillInput input)
+    {
+        if (input == null || input.EntryId <= 0) throw Oops.Oh("报检分录 id 非法");
+        var tenantId = ResolveTenantOrThrow();
+        var userId = _userManager.UserId.ToString();
+
+        var entry = await _db.Ado.SqlQuerySingleAsync<EntryGenRow>(
+            """
+            SELECT
+                e.id AS Id, e.glid AS Glid, e.jyfzr AS OwnerId, e.FINSPECTSTATUS AS InspectStatus,
+                e.FMATERIALCFG AS MaterialCode, e.wlmc AS MaterialName, e.ggxh AS Spec,
+                e.FLOTNUMBER AS LotNo, e.FAPPLYQTY AS ApplyQty, e.gysmc AS SupplierName,
+                a.FBILLNO AS ApplyBillNo, a.FBIZTYPE AS BizType
+            FROM qms_qcp_insappnentry e
+            LEFT JOIN qms_qcp_inspecapplyn a ON a.id = e.glid AND a.tenant_id = e.tenant_id
+            WHERE e.id = @Id AND e.tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", input.EntryId),
+            new SugarParameter("@TenantId", tenantId));
+
+        if (entry == null) throw Oops.Oh("报检分录不存在");
+        if (string.IsNullOrWhiteSpace(entry.ApplyBillNo)) throw Oops.Oh("报检单号缺失,无法生成检验单");
+        if (!string.Equals(entry.OwnerId?.Trim(), userId, StringComparison.Ordinal))
+            throw Oops.Oh("尚未认领或非认领人操作!");
+
+        var exists = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1)
+            FROM qms_qcp_inspbill
+            WHERE tenant_id = @TenantId AND lydjbh = @ApplyBillNo AND hid = @Hid
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@ApplyBillNo", entry.ApplyBillNo),
+            new SugarParameter("@Hid", entry.Id));
+        if (exists > 0) throw Oops.Oh("该检验任务已经有检验单,不能重新生成!");
+
+        var billNo = await AllocateIqcBillNoAsync(tenantId);
+        var billId = YitIdHelper.NextId();
+        var now = DateTime.Now;
+        var startText = now.ToString("yyyy-MM-dd HH:mm:ss");
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                UPDATE qms_qcp_insappnentry
+                SET jykssj = @Start, FINSPECTSTATUS = '检验中'
+                WHERE id = @Id AND tenant_id = @TenantId
+                """,
+                new SugarParameter("@Start", startText),
+                new SugarParameter("@Id", entry.Id),
+                new SugarParameter("@TenantId", tenantId));
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO qms_qcp_inspbill (
+                    id, tenant_id, FBILLNO, lydjbh, hid, FMATERIALCFG, wlmc, ggxh, gysmc, pch,
+                    FRINSQTY, jysl, dhsl, bhgsl, FBILLSTATUS, jyr, FINSPECTORID,
+                    FINSPESTARTDATE, FCREATETIME
+                ) VALUES (
+                    @Id, @TenantId, @FBILLNO, @Lydjbh, @Hid, @FMATERIALCFG, @Wlmc, @Ggxh, @Gysmc, @Pch,
+                    @FRINSQTY, @Jysl, 0, 0, '检验中', @Jyr, @Inspector,
+                    @StartDate, @CreateTime
+                )
+                """,
+                new SugarParameter("@Id", billId),
+                new SugarParameter("@TenantId", tenantId),
+                new SugarParameter("@FBILLNO", billNo),
+                new SugarParameter("@Lydjbh", entry.ApplyBillNo),
+                new SugarParameter("@Hid", entry.Id),
+                new SugarParameter("@FMATERIALCFG", entry.MaterialCode ?? (object)DBNull.Value),
+                new SugarParameter("@Wlmc", entry.MaterialName ?? (object)DBNull.Value),
+                new SugarParameter("@Ggxh", entry.Spec ?? (object)DBNull.Value),
+                new SugarParameter("@Gysmc", entry.SupplierName ?? (object)DBNull.Value),
+                new SugarParameter("@Pch", entry.LotNo ?? (object)DBNull.Value),
+                new SugarParameter("@FRINSQTY", entry.ApplyQty ?? 0m),
+                new SugarParameter("@Jysl", entry.ApplyQty ?? 0m),
+                new SugarParameter("@Jyr", userId),
+                new SugarParameter("@Inspector", _userManager.RealName ?? _userManager.Account ?? userId),
+                new SugarParameter("@StartDate", now),
+                new SugarParameter("@CreateTime", now));
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("生成检验单失败");
+
+        return new IqcGenerateInspBillOutput { InspBillId = billId, InspBillNo = billNo };
+    }
+
+    private async Task<string> AllocateIqcBillNoAsync(long tenantId)
+    {
+        var today = DateTime.Now.ToString("yyyyMMdd");
+        var prefix = $"IQC{today}";
+        var max = await _db.Ado.SqlQuerySingleAsync<string>(
+            """
+            SELECT FBILLNO
+            FROM qms_qcp_inspbill
+            WHERE tenant_id = @TenantId AND FBILLNO LIKE @Prefix
+            ORDER BY FBILLNO DESC
+            LIMIT 1
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@Prefix", prefix + "%"));
+
+        if (string.IsNullOrWhiteSpace(max) || max.Length < 4)
+            return $"{prefix}0001";
+
+        var last4 = max[^4..];
+        if (!int.TryParse(last4, out var serial))
+            return $"{prefix}0001";
+        if (serial >= 9999)
+            throw Oops.Oh($"当日检验单号已达上限 {prefix}9999");
+        return $"{prefix}{(serial + 1).ToString().PadLeft(4, '0')}";
+    }
+
+    private static List<long> NormalizeIds(List<long>? ids)
+    {
+        var list = (ids ?? new List<long>()).Where(x => x > 0).Distinct().ToList();
+        if (list.Count == 0) throw Oops.Oh("请至少选择一条任务");
+        if (list.Count > 200) throw Oops.Oh("单次最多操作 200 条");
+        return list;
+    }
+
+    private sealed class EntryGenRow
+    {
+        public long Id { get; set; }
+        public long? Glid { get; set; }
+        public string? OwnerId { get; set; }
+        public string? InspectStatus { get; set; }
+        public string? MaterialCode { get; set; }
+        public string? MaterialName { get; set; }
+        public string? Spec { get; set; }
+        public string? LotNo { get; set; }
+        public decimal? ApplyQty { get; set; }
+        public string? SupplierName { get; set; }
+        public string? ApplyBillNo { get; set; }
+        public string? BizType { get; set; }
+    }
+}

+ 538 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcWmsResultPushService.cs

@@ -0,0 +1,538 @@
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC P3+P5:检验员提交结果后,按字段级回写 165(MissedPrint / TransHist / 报检分录 / 检验单),
+/// 并对解禁箱码入队 <c>op=PROC</c> 调用 <c>pr_WMS_BPM_SaveInvUpShelf @TransCode=QcCheck</c> 完成质检库存→合格库过账。
+/// 不写 PurOrdRctDetail;库存过账仅经白名单存储过程。
+/// </summary>
+public sealed class IqcWmsResultPushService : ITransient
+{
+    public const string TargetSource = "DOPDEMORQ_SQLSERVER";
+    public const string ActionMp = "IQC_WMS_MP";
+    public const string ActionMpHist = "IQC_WMS_MP_HIST";
+    public const string ActionEntry = "IQC_WMS_ENTRY";
+    public const string ActionBill = "IQC_WMS_BILL";
+    public const string ActionUpShelf = "IQC_WMS_UPSHELF";
+    public const string QmsApiAction = "S5_IQC_RESULT_PUSH";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpOutboxEnqueueService _enqueue;
+    private readonly MdpOutboxWakeSignal _wake;
+    private readonly ILogger<IqcWmsResultPushService> _logger;
+
+    public IqcWmsResultPushService(
+        ISqlSugarClient db,
+        MdpOutboxEnqueueService enqueue,
+        MdpOutboxWakeSignal wake,
+        ILogger<IqcWmsResultPushService> logger)
+    {
+        _db = db;
+        _enqueue = enqueue;
+        _wake = wake;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 提交结果后入队;失败不抛(不阻断主流程)。同时保留 QMS_API 占位分支。
+    /// </summary>
+    public async Task TryEnqueueAfterSubmitAsync(long billId, long tenantId, CancellationToken ct = default)
+    {
+        try
+        {
+            await EnqueueCoreAsync(billId, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[IqcWmsPush] 入队失败 billId={BillId}", billId);
+        }
+    }
+
+    private async Task EnqueueCoreAsync(long billId, long tenantId, CancellationToken ct)
+    {
+        var bill = await _db.Ado.SqlQuerySingleAsync<BillRow>(
+            """
+            SELECT
+                id AS Id, FBILLNO AS BillNo, lydjbh AS ApplyBillNo, hid AS Hid,
+                pd AS Pd, dhsl AS Dhsl, bhgsl AS Bhgsl, clfs AS Clfs,
+                FMATERIALCFG AS MaterialCode, jyr AS Inspector
+            FROM qms_qcp_inspbill
+            WHERE id = @Id AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new SugarParameter("@Id", billId),
+            new SugarParameter("@TenantId", tenantId));
+        if (bill == null) return;
+
+        // 保留 QMS_API 占位(真实地址落地后可双投)
+        await TryEnqueueQmsApiAsync(bill, tenantId, ct);
+
+        var entry = await _db.Ado.SqlQuerySingleAsync<EntryRow>(
+            """
+            SELECT
+                id AS Id, FSRCORDERNUM AS Receiver, FMATERIALCFG AS ItemNum,
+                FAPPLYQTY AS ApplyQty
+            FROM qms_qcp_insappnentry
+            WHERE id = @Hid AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new SugarParameter("@Hid", bill.Hid ?? 0),
+            new SugarParameter("@TenantId", tenantId));
+
+        if (entry == null || string.IsNullOrWhiteSpace(entry.Receiver) || string.IsNullOrWhiteSpace(entry.ItemNum))
+        {
+            _logger.LogWarning(
+                "[IqcWmsPush] 报检分录缺失 Receiver/ItemNum,跳过 165 推送 bill={BillNo}", bill.BillNo);
+            return;
+        }
+
+        var domain = "8010";
+        var qcNbr = string.IsNullOrWhiteSpace(bill.ApplyBillNo) ? bill.BillNo : bill.ApplyBillNo!;
+        var userNo = string.IsNullOrWhiteSpace(bill.Inspector) ? "AIDOP" : bill.Inspector!;
+        var now = DateTime.Now;
+
+        // Ai-DOP clfs(0让步/1挑选/2报废) → 旧 DOP MRB 矩阵
+        var (status, qtyAccFactor, qtyRejectFactor, resultDescr) = MapResult(bill.Pd, bill.Clfs);
+
+        // 入队前预检:本库箱码(已与 165 同步)
+        var barcodes = await _db.Ado.SqlQueryAsync<BarcodeRow>(
+            """
+            SELECT BarCode, Qty, Status, IFNULL(Domain,'8010') AS Domain
+            FROM MissedPrint
+            WHERE IFNULL(Domain,'') IN ('', @Domain)
+              AND RctNbr = @Receiver AND ItemNum = @ItemNum
+              AND IFNULL(Status,'') <> 'C'
+              AND IFNULL(Type,'') <> 'Card'
+            """,
+            new SugarParameter("@Domain", domain),
+            new SugarParameter("@Receiver", entry.Receiver),
+            new SugarParameter("@ItemNum", entry.ItemNum));
+
+        if (barcodes == null || barcodes.Count == 0)
+        {
+            _logger.LogWarning(
+                "[IqcWmsPush] 无箱码可推 Receiver={Receiver} Item={Item}", entry.Receiver, entry.ItemNum);
+            return;
+        }
+
+        var sumQty = barcodes.Sum(x => x.Qty);
+        var qtyReceived = await _db.Ado.SqlQuerySingleAsync<decimal?>(
+            """
+            SELECT SUM(IFNULL(QtyReceived,0))
+            FROM PurOrdRctDetail
+            WHERE IFNULL(Domain,'') IN ('', @Domain)
+              AND Receiver = @Receiver AND ItemNum = @ItemNum
+            """,
+            new SugarParameter("@Domain", domain),
+            new SugarParameter("@Receiver", entry.Receiver),
+            new SugarParameter("@ItemNum", entry.ItemNum)) ?? 0m;
+
+        // 数量平衡:用检验数量口径(合格分支 dhsl 应等于收货数)
+        var checkQty = (bill.Dhsl ?? 0m) + (bill.Bhgsl ?? 0m);
+        if (checkQty <= 0) checkQty = entry.ApplyQty ?? 0m;
+        if (qtyReceived > 0 && checkQty > 0 && checkQty != qtyReceived)
+        {
+            _logger.LogWarning(
+                "[IqcWmsPush] 数量不平衡 check={Check} received={Recv},仍尝试推送(箱码侧)",
+                checkQty, qtyReceived);
+        }
+        if (sumQty > 0 && qtyReceived > 0 && sumQty != qtyReceived)
+        {
+            _logger.LogWarning(
+                "[IqcWmsPush] 箱码总数与收货数不一致 sum={Sum} received={Recv}", sumQty, qtyReceived);
+        }
+
+        // 待上架箱码(N=合格库 / R=不良品库);KEEP 不解禁也不上架
+        var upShelfTargets = new List<(BarcodeRow Bc, string NewStatus)>();
+
+        foreach (var bc in barcodes)
+        {
+            if (string.IsNullOrWhiteSpace(bc.BarCode)) continue;
+            var qtyAccN = qtyAccFactor ? bc.Qty : 0m;
+            var qtyRejectN = qtyRejectFactor ? bc.Qty : 0m;
+            var newStatus = status;
+            // 挑选且无箱码清单:旧系统不解禁任何箱码 → 保持原状态
+            if (status == "KEEP") newStatus = string.IsNullOrWhiteSpace(bc.Status) ? "I" : bc.Status!;
+
+            var mpPayload = JsonSerializer.Serialize(new
+            {
+                op = "UPDATE",
+                table = "MissedPrint",
+                keys = new Dictionary<string, object?>
+                {
+                    ["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
+                    ["BarCode"] = bc.BarCode
+                },
+                update = new Dictionary<string, object?>
+                {
+                    ["Status"] = newStatus,
+                    ["QtyAccN"] = qtyAccN,
+                    ["QtyRejectN"] = qtyRejectN,
+                    ["UpdateUser"] = userNo,
+                    ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss")
+                }
+            });
+
+            await _enqueue.TryEnqueueAsync(new MdpOutbox
+            {
+                TenantId = tenantId,
+                TargetSourceCode = TargetSource,
+                ActionCode = ActionMp,
+                IdemKey = $"iqc|mp|{qcNbr}|{bc.BarCode}",
+                PayloadJson = mpPayload
+            }, ct, pulse: false);
+
+            var histPayload = JsonSerializer.Serialize(new
+            {
+                op = "INSERT",
+                table = "MissedPrintTransHist",
+                keys = new Dictionary<string, object?>
+                {
+                    ["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
+                    ["BarCode"] = bc.BarCode,
+                    ["TransType"] = "来料检验-通过",
+                    ["CreateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss.fff")
+                },
+                insert = new Dictionary<string, object?>
+                {
+                    ["Domain"] = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain,
+                    ["ItemNum"] = entry.ItemNum,
+                    ["BarCode"] = bc.BarCode,
+                    ["Qty"] = bc.Qty,
+                    ["OrdNbr"] = null,
+                    ["Remark"] = "来料检验单:" + qcNbr,
+                    ["RctNbr"] = entry.Receiver,
+                    ["Status"] = newStatus,
+                    ["TransType"] = bill.Pd == 0 ? "来料检验-通过" : $"来料检验-{resultDescr}",
+                    ["CreateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss"),
+                    ["CreateUser"] = userNo,
+                    ["UpdateTime"] = now.ToString("yyyy-MM-dd HH:mm:ss"),
+                    ["UpdateUser"] = userNo
+                }
+            });
+
+            await _enqueue.TryEnqueueAsync(new MdpOutbox
+            {
+                TenantId = tenantId,
+                TargetSourceCode = TargetSource,
+                ActionCode = ActionMpHist,
+                IdemKey = $"iqc|mph|{qcNbr}|{bc.BarCode}|{now:yyyyMMddHHmmss}",
+                PayloadJson = histPayload
+            }, ct, pulse: false);
+
+            if (newStatus is "N" or "R")
+                upShelfTargets.Add((bc, newStatus));
+        }
+
+        // 报检分录状态
+        var entryPayload = JsonSerializer.Serialize(new
+        {
+            op = "UPDATE",
+            table = "qms_qcp_insappnentry",
+            keys = new Dictionary<string, object?> { ["id"] = entry.Id },
+            update = new Dictionary<string, object?>
+            {
+                ["FINSPECTSTATUS"] = "检验完成",
+                ["jywcsj"] = now.ToString("yyyy-MM-dd HH:mm:ss")
+            }
+        });
+        await _enqueue.TryEnqueueAsync(new MdpOutbox
+        {
+            TenantId = tenantId,
+            TargetSourceCode = TargetSource,
+            ActionCode = ActionEntry,
+            IdemKey = $"iqc|entry|{entry.Id}|{qcNbr}",
+            PayloadJson = entryPayload
+        }, ct, pulse: false);
+
+        // 检验单推 165(含本库 id;用 FBILLNO 做自然键 UPSERT)
+        var billPayload = JsonSerializer.Serialize(new
+        {
+            op = "UPSERT",
+            table = "qms_qcp_inspbill",
+            keys = new Dictionary<string, object?> { ["FBILLNO"] = bill.BillNo },
+            insert = new Dictionary<string, object?>
+            {
+                ["id"] = bill.Id,
+                ["FBILLNO"] = bill.BillNo,
+                ["lydjbh"] = bill.ApplyBillNo,
+                ["hid"] = entry.Id,
+                ["FMATERIALCFG"] = bill.MaterialCode,
+                ["pd"] = bill.Pd,
+                ["dhsl"] = bill.Dhsl,
+                ["bhgsl"] = bill.Bhgsl,
+                ["clfs"] = bill.Clfs,
+                ["jyr"] = bill.Inspector,
+                ["FRINSQTY"] = entry.ApplyQty
+            },
+            update = new Dictionary<string, object?>
+            {
+                ["pd"] = bill.Pd,
+                ["dhsl"] = bill.Dhsl,
+                ["bhgsl"] = bill.Bhgsl,
+                ["clfs"] = bill.Clfs,
+                ["jyr"] = bill.Inspector
+            }
+        });
+        // 无上架候选时由 bill 唤醒;有候选时由最后一条 UPSHELF 唤醒
+        await _enqueue.TryEnqueueAsync(new MdpOutbox
+        {
+            TenantId = tenantId,
+            TargetSourceCode = TargetSource,
+            ActionCode = ActionBill,
+            IdemKey = $"iqc|bill|{bill.BillNo}",
+            PayloadJson = billPayload
+        }, ct, pulse: upShelfTargets.Count == 0);
+
+        // 本库同步置检验完成(写回过程在旧系统自动写;Ai-DOP 权威侧先落本地)
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE qms_qcp_insappnentry
+            SET FINSPECTSTATUS = '检验完成', jywcsj = @Now
+            WHERE id = @Id AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@Now", now.ToString("yyyy-MM-dd HH:mm:ss")),
+            new SugarParameter("@Id", entry.Id),
+            new SugarParameter("@TenantId", tenantId));
+
+        var upShelfEnqueued = await EnqueueUpShelfAsync(
+            upShelfTargets, entry, domain, qcNbr!, userNo, tenantId, ct);
+        // 有候选但全部跳过(暂收 / 无默认库位)时 bill 未 pulse,补唤醒
+        if (upShelfTargets.Count > 0 && upShelfEnqueued == 0)
+            _wake.Pulse();
+    }
+
+    /// <summary>
+    /// 合格/让步 → ItemMaster.Location;退货 → PurOrdControl.RejectInspectionLocation(165 resolve)。
+    /// 暂收路径(ReceiptQty&gt;0)不入队,避免与 165 IQC 自带上架重复(任务书 U-F)。
+    /// </summary>
+    /// <returns>实际入队条数。</returns>
+    private async Task<int> EnqueueUpShelfAsync(
+        List<(BarcodeRow Bc, string NewStatus)> targets,
+        EntryRow entry,
+        string domain,
+        string qcNbr,
+        string userNo,
+        long tenantId,
+        CancellationToken ct)
+    {
+        if (targets.Count == 0) return 0;
+
+        var receiptQty = await _db.Ado.SqlQuerySingleAsync<decimal?>(
+            """
+            SELECT SUM(IFNULL(d.ReceiptQty, 0))
+            FROM PurOrdRctDetail r
+            INNER JOIN PurOrdDetail d
+              ON IFNULL(d.Domain,'') IN ('', @Domain)
+             AND d.PurOrd = r.OrdNbr AND d.Line = r.OrdLine
+            WHERE IFNULL(r.Domain,'') IN ('', @Domain)
+              AND r.Receiver = @Receiver AND r.ItemNum = @ItemNum
+            """,
+            new SugarParameter("@Domain", domain),
+            new SugarParameter("@Receiver", entry.Receiver!),
+            new SugarParameter("@ItemNum", entry.ItemNum!)) ?? 0m;
+        if (receiptQty > 0)
+        {
+            _logger.LogWarning(
+                "[IqcWmsPush] ReceiptQty={Qty}>0(暂收路径),跳过 QcCheck 上架入队 Receiver={Receiver}",
+                receiptQty, entry.Receiver);
+            return 0;
+        }
+
+        string? passShelf = null;
+        if (targets.Any(t => t.NewStatus == "N"))
+        {
+            passShelf = await _db.Ado.SqlQuerySingleAsync<string>(
+                """
+                SELECT Location
+                FROM ItemMaster
+                WHERE ItemNum = @ItemNum
+                  AND IFNULL(Domain,'') IN ('', @Domain)
+                LIMIT 1
+                """,
+                new SugarParameter("@ItemNum", entry.ItemNum!),
+                new SugarParameter("@Domain", domain));
+            if (string.IsNullOrWhiteSpace(passShelf))
+            {
+                _logger.LogWarning(
+                    "[IqcWmsPush] ItemMaster.Location 为空,跳过合格品上架 Item={Item}", entry.ItemNum);
+            }
+        }
+
+        var enqueued = 0;
+        var pendingPulse = false;
+        for (var i = 0; i < targets.Count; i++)
+        {
+            var (bc, newStatus) = targets[i];
+            var bcDomain = string.IsNullOrWhiteSpace(bc.Domain) ? domain : bc.Domain!;
+
+            Dictionary<string, object?> args;
+            Dictionary<string, object>? resolve;
+
+            if (newStatus == "N")
+            {
+                if (string.IsNullOrWhiteSpace(passShelf)) continue;
+                args = new Dictionary<string, object?>
+                {
+                    ["Domain"] = bcDomain,
+                    ["Shelf"] = passShelf,
+                    ["TransCode"] = "QcCheck",
+                    ["UserNo"] = userNo,
+                    ["IsProcCall"] = 1
+                };
+                resolve = new Dictionary<string, object>
+                {
+                    ["Details"] = new Dictionary<string, object>
+                    {
+                        ["table"] = "MissedPrint",
+                        ["column"] = "RecID",
+                        ["match"] = new Dictionary<string, object?>
+                        {
+                            ["Domain"] = bcDomain,
+                            ["BarCode"] = bc.BarCode
+                        }
+                    }
+                };
+            }
+            else // R:不良品库位由 165 PurOrdControl 现查
+            {
+                args = new Dictionary<string, object?>
+                {
+                    ["Domain"] = bcDomain,
+                    ["TransCode"] = "QcCheck",
+                    ["UserNo"] = userNo,
+                    ["IsProcCall"] = 1
+                };
+                resolve = new Dictionary<string, object>
+                {
+                    ["Details"] = new Dictionary<string, object>
+                    {
+                        ["table"] = "MissedPrint",
+                        ["column"] = "RecID",
+                        ["match"] = new Dictionary<string, object?>
+                        {
+                            ["Domain"] = bcDomain,
+                            ["BarCode"] = bc.BarCode
+                        }
+                    },
+                    ["Shelf"] = new Dictionary<string, object>
+                    {
+                        ["table"] = "PurOrdControl",
+                        ["column"] = "RejectInspectionLocation",
+                        ["match"] = new Dictionary<string, object?> { ["Domain"] = bcDomain }
+                    }
+                };
+            }
+
+            var payload = JsonSerializer.Serialize(new
+            {
+                op = "PROC",
+                proc = "pr_WMS_BPM_SaveInvUpShelf",
+                args,
+                resolve,
+                output = "ReturnMsg",
+                success = "成功",
+                idempotent = new[] { "已是此货架", "已是此位置" }
+            });
+
+            // 先不 pulse,循环结束后对最后成功入队的一条补唤醒(避免中间 continue 导致未 pulse)
+            var ok = await _enqueue.TryEnqueueAsync(new MdpOutbox
+            {
+                TenantId = tenantId,
+                TargetSourceCode = TargetSource,
+                ActionCode = ActionUpShelf,
+                IdemKey = $"iqc|upshelf|{qcNbr}|{bc.BarCode}",
+                PayloadJson = payload
+            }, ct, pulse: false);
+            if (ok)
+            {
+                enqueued++;
+                pendingPulse = true;
+            }
+        }
+
+        if (pendingPulse) _wake.Pulse();
+        return enqueued;
+    }
+
+    private async Task TryEnqueueQmsApiAsync(BillRow bill, long tenantId, CancellationToken ct)
+    {
+        var billNo = string.IsNullOrWhiteSpace(bill.BillNo) ? bill.Id.ToString() : bill.BillNo!;
+        var payload = JsonSerializer.Serialize(new
+        {
+            path = "/iqc/result",
+            method = "POST",
+            body = new
+            {
+                billId = bill.Id,
+                billNo,
+                pd = bill.Pd,
+                dhsl = bill.Dhsl,
+                bhgsl = bill.Bhgsl,
+                action = QmsApiAction
+            }
+        });
+        await _enqueue.TryEnqueueAsync(new MdpOutbox
+        {
+            TenantId = tenantId,
+            TargetSourceCode = "QMS_API",
+            ActionCode = QmsApiAction,
+            IdemKey = billNo,
+            PayloadJson = payload
+        }, ct, pulse: false);
+    }
+
+    /// <summary>
+    /// 返回 (箱码Status, 是否QtyAccN=Qty, 是否QtyRejectN=Qty, 描述)。
+    /// Status=KEEP 表示挑选且无箱码清单时保持原状态。
+    /// </summary>
+    private static (string Status, bool QtyAcc, bool QtyReject, string Descr) MapResult(int? pd, long? clfs)
+    {
+        if (pd == 0)
+            return ("N", true, false, "通过");
+
+        // Ai-DOP:0让步 / 1挑选 / 2报废
+        return clfs switch
+        {
+            0 => ("N", true, false, "MRB让步接收"),
+            1 => ("KEEP", false, false, "MRB挑选使用"),
+            2 => ("R", false, true, "MRB退货"),
+            _ => ("R", false, true, "退货")
+        };
+    }
+
+    private sealed class BillRow
+    {
+        public long Id { get; set; }
+        public string? BillNo { get; set; }
+        public string? ApplyBillNo { get; set; }
+        public long? Hid { get; set; }
+        public int? Pd { get; set; }
+        public decimal? Dhsl { get; set; }
+        public decimal? Bhgsl { get; set; }
+        public long? Clfs { get; set; }
+        public string? MaterialCode { get; set; }
+        public string? Inspector { get; set; }
+    }
+
+    private sealed class EntryRow
+    {
+        public long Id { get; set; }
+        public string? Receiver { get; set; }
+        public string? ItemNum { get; set; }
+        public decimal? ApplyQty { get; set; }
+    }
+
+    private sealed class BarcodeRow
+    {
+        public string? BarCode { get; set; }
+        public decimal Qty { get; set; }
+        public string? Status { get; set; }
+        public string? Domain { get; set; }
+    }
+}