Selaa lähdekoodia

feat(s6): add automatic process spec resolution

Phase 1B 过程检规自动唯一解析 + 检规快照(UATAdminA 租户 838257186181189 Golden 验证):
- S6ProcessSpecResolver:运行期只查规范映射表 ado_s6_ipqc_spec_material_map(不 split wlbm),
  按 wjbh 分组裁决(同 wjbh 取 sxrj 最新版本;≥2 wjbh 歧义);4 态 MATCHED/NO_MATCH/AMBIGUOUS/INVALID_SPEC
- S6ProcessInspectionService.prepare:CurrentTenant(AidopTenantScope)+生产指令→解析,
  仅 MATCHED 幂等生成/复用检验单+冻结检规快照(头+明细,同一事务);detail 只读;无人工选择器
- 迁移 1.0.445:qms_gcjygfzb 加 lrlx(ResultType) + 4 表(map/bill/snapshot head/line) + Golden(租户838)
- 前端 过程检验 按钮启用→prepare→MATCHED 开只读快照抽屉/其余状态阻断
- 契约+单测 28 项:Decide MATCHED/NO_MATCH/AMBIGUOUS/VERSION/INVALID + tenant/事务/幂等/快照/ResultType
runtime(UATAdminA API+DB):A MATCHED、B NO_MATCH、C AMBIGUOUS(2 wjbh)、D VERSION(取 A.1)、E 幂等(同单);
snapshot=source;无行膨胀;租户隔离(797 不解析 838 Golden)。迁移已落库(1.0.445 Success)。
YY968XX 3 päivää sitten
vanhempi
commit
cef93ee8a1

+ 1 - 1
Web/package.json

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

+ 56 - 0
Web/src/views/aidop/s6/api/productionInstruction.ts

@@ -63,3 +63,59 @@ export function fetchProductionInstructionDetail(workOrderNo: string) {
 		.get<ProductionInstructionRow>('/api/S6ProductionInstruction/detail', { params: { workOrderNo } })
 		.get<ProductionInstructionRow>('/api/S6ProductionInstruction/detail', { params: { workOrderNo } })
 		.then((r) => r.data);
 		.then((r) => r.data);
 }
 }
+
+// ── S6 Phase 1B 过程检验:自动唯一解析检规 + 生成检验单 + 快照 ──
+export interface ProcessInspectionPrepareResult {
+	status: 'MATCHED' | 'NO_MATCH' | 'AMBIGUOUS' | 'INVALID_SPEC' | string;
+	message?: string;
+	billId?: number | null;
+	reused?: boolean;
+	specCode?: string;
+	specVersion?: string;
+	candidateCount?: number;
+}
+
+export interface ProcessInspectionSnapshotLine {
+	id?: number;
+	inspectionItem?: string;
+	processCode?: string;
+	processName?: string;
+	method?: string;
+	specValue?: string;
+	techStandard?: string;
+	lowerLimit?: string;
+	upperLimit?: string;
+	inspectionFrequency?: string;
+	resultType?: string;
+	sortNo?: number;
+}
+
+export interface ProcessInspectionDetail {
+	billId?: number;
+	workOrderNo?: string;
+	lotSerial?: string;
+	itemCode?: string;
+	itemName?: string;
+	specification?: string;
+	specId?: number;
+	specCode?: string;
+	specVersion?: string;
+	effectiveDate?: string;
+	inspectionStatus?: string;
+	createdByName?: string;
+	createTime?: string;
+	lines?: ProcessInspectionSnapshotLine[];
+}
+
+// prepare:前端只传 workOrderNo,后端自解析租户+检规。
+export function prepareProcessInspection(workOrderNo: string) {
+	return service
+		.post<ProcessInspectionPrepareResult>('/api/S6ProcessInspection/prepare', { workOrderNo })
+		.then((r) => r.data);
+}
+
+export function fetchProcessInspectionDetail(billId: number) {
+	return service
+		.get<ProcessInspectionDetail>('/api/S6ProcessInspection/detail', { params: { billId } })
+		.then((r) => r.data);
+}

+ 92 - 7
Web/src/views/aidop/s6/production-record/productionInstructionList.vue

@@ -61,9 +61,7 @@
 			</el-table-column>
 			</el-table-column>
 			<el-table-column label="操作" width="130" align="center" fixed="right">
 			<el-table-column label="操作" width="130" align="center" fixed="right">
 				<template #default="{ row }">
 				<template #default="{ row }">
-					<el-tooltip content="过程检验入口将在 Phase 1B 接入检规选择" placement="top">
-						<el-button link type="primary" :disabled="true" @click="onProcessInspect(row)">过程检验</el-button>
-					</el-tooltip>
+					<el-button link type="primary" :loading="inspecting === row.workOrderNo" @click="onProcessInspect(row)">过程检验</el-button>
 				</template>
 				</template>
 			</el-table-column>
 			</el-table-column>
 			<template #empty>
 			<template #empty>
@@ -82,19 +80,71 @@
 				@size-change="onSizeChange"
 				@size-change="onSizeChange"
 			/>
 			/>
 		</div>
 		</div>
+
+		<el-drawer v-model="detailVisible" title="过程检验单" size="62%" :destroy-on-close="true">
+			<div v-loading="detailLoading">
+				<el-descriptions :column="3" border size="small" title="检验单信息">
+					<el-descriptions-item label="工单编号">{{ detail?.workOrderNo || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="批次号">{{ detail?.lotSerial || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="状态">{{ detail?.inspectionStatus || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="物料编码">{{ detail?.itemCode || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="物料名称">{{ detail?.itemName || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="规格型号">{{ detail?.specification || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="检规编号">{{ detail?.specCode || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="检规版本">{{ detail?.specVersion || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="生效日期">{{ detail?.effectiveDate || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="创建人">{{ detail?.createdByName || '—' }}</el-descriptions-item>
+					<el-descriptions-item label="创建时间">{{ fmtDate(detail?.createTime) }}</el-descriptions-item>
+				</el-descriptions>
+
+				<div class="snap-title">检规快照明细(Phase 1C 接入检验录入)</div>
+				<el-table :data="detail?.lines || []" border stripe size="small" max-height="360">
+					<el-table-column type="index" label="#" width="50" align="center" />
+					<el-table-column prop="processCode" label="工序代号" min-width="90" />
+					<el-table-column prop="processName" label="工序名称" min-width="90" />
+					<el-table-column prop="inspectionItem" label="检验项目" min-width="120" show-overflow-tooltip />
+					<el-table-column prop="resultType" label="录入类型" min-width="110">
+						<template #default="{ row }">
+							<el-tag size="small" :type="row.resultType === 'NUMERIC' ? 'success' : 'info'" disable-transitions>{{ row.resultType || '—' }}</el-tag>
+						</template>
+					</el-table-column>
+					<el-table-column prop="method" label="检验方法" min-width="90" show-overflow-tooltip />
+					<el-table-column prop="specValue" label="检验规格" min-width="90" show-overflow-tooltip />
+					<el-table-column prop="lowerLimit" label="下限" min-width="70" />
+					<el-table-column prop="upperLimit" label="上限" min-width="70" />
+					<el-table-column prop="inspectionFrequency" label="检验频次" min-width="90" show-overflow-tooltip />
+					<el-table-column label="实测值" min-width="120" align="center">
+						<template #default>
+							<el-input size="small" disabled placeholder="Phase 1C 录入" />
+						</template>
+					</el-table-column>
+				</el-table>
+			</div>
+		</el-drawer>
 	</AidopDemoShell>
 	</AidopDemoShell>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import { reactive, ref, computed } from 'vue';
 import { reactive, ref, computed } from 'vue';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
-import { fetchProductionInstructionPage, type ProductionInstructionRow } from '../api/productionInstruction';
+import {
+	fetchProductionInstructionPage,
+	prepareProcessInspection,
+	fetchProcessInspectionDetail,
+	type ProductionInstructionRow,
+	type ProcessInspectionDetail,
+} from '../api/productionInstruction';
 
 
 const loading = ref(false);
 const loading = ref(false);
 const rows = ref<ProductionInstructionRow[]>([]);
 const rows = ref<ProductionInstructionRow[]>([]);
 const total = ref(0);
 const total = ref(0);
 
 
+const inspecting = ref<string>('');
+const detailVisible = ref(false);
+const detailLoading = ref(false);
+const detail = ref<ProcessInspectionDetail | null>(null);
+
 const query = reactive({
 const query = reactive({
 	page: 1,
 	page: 1,
 	pageSize: 10,
 	pageSize: 10,
@@ -163,8 +213,43 @@ function onSizeChange() {
 	loadList();
 	loadList();
 }
 }
 
 
-function onProcessInspect(_row: ProductionInstructionRow) {
-	// Phase 1A 不生成检验单,不做写入;入口待 Phase 1B 接入检规选择后开放。
+// Phase 1B:点击 → 后端自动唯一解析检规(无人工选择)。
+// MATCHED 幂等生成/复用检验单 + 快照 → 打开只读详情;其余状态明确阻断、不建单。
+async function onProcessInspect(row: ProductionInstructionRow) {
+	const wo = row.workOrderNo;
+	if (!wo || inspecting.value) return;
+	inspecting.value = wo;
+	try {
+		const r = await prepareProcessInspection(wo);
+		if (r?.status === 'MATCHED' && r.billId) {
+			await openDetail(r.billId);
+		} else if (r?.status === 'NO_MATCH') {
+			await ElMessageBox.alert(r.message || '当前物料未配置有效过程检验规范,请维护 S0 过程检验规范。', '无法生成过程检验单', { type: 'warning' });
+		} else if (r?.status === 'AMBIGUOUS') {
+			await ElMessageBox.alert(r.message || '当前物料存在多个同时有效过程检验规范,请修正 S0 规范配置。', '检规存在歧义', { type: 'warning' });
+		} else if (r?.status === 'INVALID_SPEC') {
+			await ElMessageBox.alert(r.message || '过程检验规范配置无效,请检查版本/生效日期/明细。', '检规无效', { type: 'warning' });
+		} else {
+			await ElMessageBox.alert(r?.message || '无法解析过程检验规范。', '无法生成过程检验单', { type: 'warning' });
+		}
+	} catch {
+		ElMessage.error('过程检验准备失败');
+	} finally {
+		inspecting.value = '';
+	}
+}
+
+async function openDetail(billId: number) {
+	detailVisible.value = true;
+	detailLoading.value = true;
+	detail.value = null;
+	try {
+		detail.value = await fetchProcessInspectionDetail(billId);
+	} catch {
+		ElMessage.error('检验单详情加载失败');
+	} finally {
+		detailLoading.value = false;
+	}
 }
 }
 
 
 loadList();
 loadList();

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.448</AssemblyVersion>
-    <FileVersion>1.0.448</FileVersion>
-    <Version>1.0.448</Version>
+    <AssemblyVersion>1.0.449</AssemblyVersion>
+    <FileVersion>1.0.449</FileVersion>
+    <Version>1.0.449</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -634,6 +634,9 @@
     <None Update="UpdateScripts\1.0.443.sql">
     <None Update="UpdateScripts\1.0.443.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.445.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 129 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.445.sql

@@ -0,0 +1,129 @@
+-- S6 Phase 1B · 过程检规物料映射 + 自动唯一解析 + 检规快照(幂等)
+-- 仅针对 UATAdminA 所属租户 838257186181189 补最小 Golden 数据;不污染其他租户。
+-- 表名/字段服从 S6/QMS 现有命名。全部 nullable/兼容历史;重跑不重复。
+
+-- ============ ① S0 过程检规明细 ResultType 列(lrlx:NUMERIC / NON_NUMERIC)============
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='qms_gcjygfzb' AND COLUMN_NAME='lrlx');
+SET @sql := IF(@exist=0, 'ALTER TABLE qms_gcjygfzb ADD COLUMN lrlx VARCHAR(20) NULL COMMENT ''录入类型 ResultType: NUMERIC/NON_NUMERIC''', 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ============ ② 过程检规↔物料 规范映射表(运行期 Resolver 只查此表,不再 split wlbm)============
+CREATE TABLE IF NOT EXISTS ado_s6_ipqc_spec_material_map (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    spec_id BIGINT NOT NULL,
+    spec_code VARCHAR(100) NULL COMMENT 'wjbh 文件编号',
+    spec_version VARCHAR(50) NULL COMMENT 'bb 版本',
+    item_code VARCHAR(100) NOT NULL,
+    match_status VARCHAR(20) NOT NULL DEFAULT 'EXACT' COMMENT 'EXACT/NOT_FOUND/GLUE_UNRESOLVED/INVALID',
+    source_raw_value TEXT NULL COMMENT 'wlbm 原始片段',
+    create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_s6_spec_item (tenant_id, spec_id, item_code),
+    KEY idx_s6_map_item (tenant_id, item_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 过程检规↔物料 规范映射';
+
+-- ============ ③ 过程检验单(Phase 1B 最小业务骨架)============
+CREATE TABLE IF NOT EXISTS ado_s6_process_inspection_bill (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    factory_id BIGINT NULL,
+    work_order_no VARCHAR(100) NOT NULL,
+    lot_serial VARCHAR(100) NULL,
+    item_code VARCHAR(100) NULL,
+    item_name VARCHAR(200) NULL,
+    specification VARCHAR(200) NULL,
+    spec_id BIGINT NULL,
+    spec_code VARCHAR(100) NULL,
+    spec_version VARCHAR(50) NULL,
+    inspection_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ACTIVE(Phase 1C 扩展状态机)',
+    created_by BIGINT NULL,
+    created_by_name VARCHAR(100) NULL,
+    create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    KEY idx_s6_bill_wo (tenant_id, work_order_no, inspection_status),
+    KEY idx_s6_bill_spec (tenant_id, spec_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 过程检验单(Phase 1B 骨架)';
+
+-- ============ ④ 检规快照-头 ============
+CREATE TABLE IF NOT EXISTS ado_s6_process_inspection_snapshot_head (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    bill_id BIGINT NOT NULL,
+    spec_id BIGINT NULL,
+    spec_code VARCHAR(100) NULL,
+    spec_version VARCHAR(50) NULL,
+    effective_date VARCHAR(50) NULL,
+    item_code VARCHAR(100) NULL,
+    specification VARCHAR(200) NULL,
+    source_tenant_id BIGINT NULL,
+    create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_s6_snap_head_bill (bill_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 检规快照-头';
+
+-- ============ ⑤ 检规快照-明细 ============
+CREATE TABLE IF NOT EXISTS ado_s6_process_inspection_snapshot_line (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tenant_id BIGINT NOT NULL,
+    bill_id BIGINT NOT NULL,
+    source_line_id BIGINT NULL COMMENT 'qms_gcjygfzb.id',
+    inspection_item VARCHAR(500) NULL COMMENT 'jyxm',
+    process_code VARCHAR(100) NULL COMMENT 'gxdh',
+    process_name VARCHAR(200) NULL COMMENT 'gxmc',
+    method VARCHAR(500) NULL COMMENT 'jyff',
+    spec_value VARCHAR(500) NULL COMMENT 'jygg 检验规格',
+    tech_standard VARCHAR(500) NULL COMMENT 'jsbz 技术标准',
+    lower_limit VARCHAR(100) NULL COMMENT 'xx',
+    upper_limit VARCHAR(100) NULL COMMENT 'sx',
+    inspection_frequency VARCHAR(200) NULL COMMENT 'jypc',
+    result_type VARCHAR(20) NULL COMMENT 'lrlx NUMERIC/NON_NUMERIC',
+    sort_no INT NULL,
+    create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+    KEY idx_s6_snap_line_bill (tenant_id, bill_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S6 检规快照-明细';
+
+-- ============ ⑥ Golden 数据(租户 838257186181189)============
+SET @T := 838257186181189;
+
+-- ⑥.1 新增 AMBIGUOUS 伙伴检规 UATD-GC-006(物料 3124C0015 的第二张、不同 wjbh,同样有效)
+INSERT INTO qms_gcjygf (id, tenant_id, syxh, wjbh, bb, sxrj, wlbm, version)
+SELECT 3000000000000011006, @T, 'UAT-AMBIG-型号', 'UATD-GC-006', 'A.0', '2026-08-28', '3124C0015', 1
+WHERE NOT EXISTS (SELECT 1 FROM qms_gcjygf WHERE id=3000000000000011006);
+
+-- ⑥.2 新增 VERSION 更高版本 UATD-GC-003 v2(同 wjbh,sxrj 更新 → Resolver 应取此版本;物料 3221C0031)
+INSERT INTO qms_gcjygf (id, tenant_id, syxh, wjbh, bb, sxrj, wlbm, version)
+SELECT 3000000000000011007, @T, '压(FBE6035B)', 'UATD-GC-003', 'A.1', '2026-08-29', '3221C0031', 2
+WHERE NOT EXISTS (SELECT 1 FROM qms_gcjygf WHERE id=3000000000000011007);
+
+-- ⑥.3 明细行(每检规 2 行:1 NUMERIC + 1 NON_NUMERIC,含 lrlx;glid 内联直指归属检规)。固定 id 保证幂等。
+--     GC-001(11001,91HC0497 MATCHED) GC-002(11002,3124C0015) GC-006(11006,3124C0015 AMBIG)
+--     GC-003v1(11003,3221C0031) GC-003v2(11007,3221C0031) GC-004(11004,3121C0035) GC-005(11005,322AD001)
+INSERT INTO qms_gcjygfzb (id, tenant_id, glid, gxdh, gxmc, jyxm, jyff, jygg, jsbz, jypc, xx, sx, lrlx)
+SELECT * FROM (
+    SELECT 3000000000000012001 id, @T tenant_id, 3000000000000011001 glid, 'OP10' gxdh, '装配' gxmc, '关键尺寸A' jyxm, '游标卡尺' jyff, '10.0±0.2' jygg, 'GB/T-A' jsbz, '首件' jypc, '9.8' xx, '10.2' sx, 'NUMERIC' lrlx
+    UNION ALL SELECT 3000000000000012002, @T, 3000000000000011001, 'OP10', '装配', '外观', '目视', '无划伤/无毛刺', '表面完好', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012003, @T, 3000000000000011002, 'OP20', '成型', '厚度B', '千分尺', '2.00±0.05', 'GB/T-B', '首件', '1.95', '2.05', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012004, @T, 3000000000000011002, 'OP20', '成型', '色泽', '目视', '色差合格', '标准色卡', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012005, @T, 3000000000000011006, 'OP30', '注塑', '孔径C', '塞规', '5.00±0.10', 'GB/T-C', '首件', '4.90', '5.10', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012006, @T, 3000000000000011006, 'OP30', '注塑', '飞边', '目视', '无飞边', '外观规范', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012007, @T, 3000000000000011003, 'OP40', '压合', '压力D', '压力计', '50±2N', 'GB/T-D', '首件', '48', '52', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012008, @T, 3000000000000011003, 'OP40', '压合', '密封', '气密测试', '无泄漏', '密封标准', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012009, @T, 3000000000000011007, 'OP50', '包装', '长度E', '卷尺', '100±1', 'GB/T-E', '首件', '99', '101', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012010, @T, 3000000000000011007, 'OP50', '包装', '标签', '目视', '标签正确', '标签规范', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012011, @T, 3000000000000011004, 'OP60', '终检', '重量F', '电子秤', '20±0.5g', 'GB/T-F', '末件', '19.5', '20.5', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012012, @T, 3000000000000011004, 'OP60', '终检', '清洁度', '目视', '无异物', '洁净标准', '巡检', NULL, NULL, 'NON_NUMERIC'
+    UNION ALL SELECT 3000000000000012013, @T, 3000000000000011005, 'OP70', '装配', '扭矩G', '扭矩扳手', '8±0.5Nm', 'GB/T-G', '首件', '7.5', '8.5', 'NUMERIC'
+    UNION ALL SELECT 3000000000000012014, @T, 3000000000000011005, 'OP70', '装配', '功能', '功能测试', '功能正常', '功能规范', '巡检', NULL, NULL, 'NON_NUMERIC'
+) g
+WHERE NOT EXISTS (SELECT 1 FROM qms_gcjygfzb z WHERE z.id=g.id);
+
+-- ⑥.4 规范映射(EXACT):为 7 张 Golden 检规各建 1 条 (spec,item)。幂等 UK(tenant,spec_id,item_code)。
+INSERT INTO ado_s6_ipqc_spec_material_map (tenant_id, spec_id, spec_code, spec_version, item_code, match_status, source_raw_value)
+SELECT @T, g.id, g.wjbh, g.bb, g.wlbm, 'EXACT', g.wlbm
+FROM qms_gcjygf g
+WHERE g.tenant_id=@T AND g.id IN (
+    3000000000000011001,3000000000000011002,3000000000000011003,
+    3000000000000011004,3000000000000011005,3000000000000011006,3000000000000011007)
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_s6_ipqc_spec_material_map m
+    WHERE m.tenant_id=@T AND m.spec_id=g.id AND m.item_code=g.wlbm);

+ 102 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionContractTests.cs

@@ -0,0 +1,102 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6 Phase 1B 源码契约测试:解析器 map-only + tenant-safe;生成服务 事务/幂等/快照/仅MATCHED写;前端无人工选择器。
+/// </summary>
+public class S6ProcessInspectionContractTests
+{
+    private static string Resolver() => File.ReadAllText(FindFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessSpecResolver.cs"));
+    private static string Service() => File.ReadAllText(FindFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionService.cs"));
+    private static string Vue() => File.ReadAllText(FindFile("Web", "src", "views", "aidop", "s6", "production-record", "productionInstructionList.vue"));
+
+    [Fact]
+    public void Resolver_RuntimeReadsMapTable_TenantAndItemScoped()
+    {
+        var s = Resolver();
+        Assert.Contains("FROM ado_s6_ipqc_spec_material_map", s);
+        Assert.Contains("m.tenant_id=@TenantId AND m.item_code=@ItemCode AND m.match_status='EXACT'", s);
+        Assert.Contains("g.tenant_id=@TenantId", s); // 检规同租户
+    }
+
+    [Fact]
+    public void Resolver_NoIdDescFallback_NoManualPick()
+    {
+        var s = Resolver();
+        Assert.DoesNotContain("ORDER BY g.id DESC", s);
+        Assert.DoesNotContain("ORDER BY id DESC LIMIT 1", s);
+        // 裁决按 wjbh 分组
+        Assert.Contains("GroupBy(c => (c.Wjbh", s);
+    }
+
+    [Fact]
+    public void Service_StrictTenantScope_EveryQuery()
+    {
+        var s = Service();
+        Assert.Contains("AidopTenantScope.ResolveOrThrow", s);
+        Assert.DoesNotContain("tenant_id IS NULL", s);
+        Assert.DoesNotContain("tenant_id = 0", s);
+        var n = s.Split("tenant_id=@TenantId").Length - 1;
+        Assert.True(n >= 4, $"expected tenant predicate on load/idempotency/insert/snapshot/detail, found {n}");
+    }
+
+    [Fact]
+    public void Service_OnlyMatchedWrites()
+    {
+        var s = Service();
+        Assert.Contains("resolve.Status != S6SpecResolveStatus.Matched", s);
+        Assert.Contains("return res; // 非唯一解析:不写库", s);
+        Assert.Contains("resolve.CandidateCount != 1", s);
+    }
+
+    [Fact]
+    public void Service_TransactionalGeneration()
+    {
+        var s = Service();
+        Assert.Contains("BeginTran()", s);
+        Assert.Contains("CommitTran()", s);
+        Assert.Contains("RollbackTran()", s);
+    }
+
+    [Fact]
+    public void Service_IdempotentReuseActiveBill()
+    {
+        var s = Service();
+        Assert.Contains("inspection_status IN ('DRAFT','ACTIVE')", s);
+        Assert.Contains("Reused = true", s);
+    }
+
+    [Fact]
+    public void Service_SnapshotFreezesFromS0Detail_WithResultType()
+    {
+        var s = Service();
+        Assert.Contains("INSERT INTO ado_s6_process_inspection_snapshot_head", s);
+        Assert.Contains("INSERT INTO ado_s6_process_inspection_snapshot_line", s);
+        Assert.Contains("FROM qms_gcjygfzb z", s);       // 快照冻结自 S0 明细
+        Assert.Contains("z.lrlx", s);                     // ResultType 进快照
+    }
+
+    [Fact]
+    public void Frontend_NoManualSpecSelector_CallsPrepare()
+    {
+        var v = Vue();
+        Assert.Contains("prepareProcessInspection", v);
+        Assert.DoesNotContain(":disabled=\"true\"", v);   // 按钮不再禁用
+        // 无人工检规下拉/选择器
+        Assert.DoesNotContain("specSelect", v);
+        Assert.DoesNotContain("选择检规", v);
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 93 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessSpecResolverTests.cs

@@ -0,0 +1,93 @@
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6 Phase 1B 过程检规解析器 裁决逻辑单测(纯函数 Decide,无 DB)。
+/// 覆盖 MATCHED / NO_MATCH / AMBIGUOUS / VERSION / INVALID_SPEC + 日期归一化 + tokenize。
+/// </summary>
+public class S6ProcessSpecResolverTests
+{
+    private static S6SpecCandidate C(long id, string wjbh, int version, string sxrj, int detail) =>
+        new() { Id = id, Wjbh = wjbh, Bb = "A." + version, Version = version, Sxrj = sxrj, DetailCount = detail };
+
+    private static readonly DateTime Ref = new(2026, 9, 1);
+
+    [Fact]
+    public void Matched_SingleEffectiveWjbh()
+    {
+        var r = S6ProcessSpecResolver.Decide(new[] { C(101, "UATD-GC-001", 1, "2026-08-28", 2) }, Ref);
+        Assert.Equal(S6SpecResolveStatus.Matched, r.Status);
+        Assert.Equal(101, r.MatchedSpecId);
+        Assert.Equal(1, r.CandidateCount);
+    }
+
+    [Fact]
+    public void NoMatch_EmptyCandidates()
+    {
+        var r = S6ProcessSpecResolver.Decide(System.Array.Empty<S6SpecCandidate>(), Ref);
+        Assert.Equal(S6SpecResolveStatus.NoMatch, r.Status);
+    }
+
+    [Fact]
+    public void Ambiguous_TwoDistinctWjbhBothEffective()
+    {
+        var r = S6ProcessSpecResolver.Decide(new[]
+        {
+            C(201, "UATD-GC-002", 1, "2026-08-28", 2),
+            C(202, "UATD-GC-006", 1, "2026-08-28", 2),
+        }, Ref);
+        Assert.Equal(S6SpecResolveStatus.Ambiguous, r.Status);
+        Assert.Equal(2, r.CandidateCount);
+        Assert.Null(r.MatchedSpecId);
+    }
+
+    [Fact]
+    public void Version_SameWjbhPicksNewestEffective()
+    {
+        var r = S6ProcessSpecResolver.Decide(new[]
+        {
+            C(301, "UATD-GC-003", 1, "2026-08-28", 2),
+            C(302, "UATD-GC-003", 2, "2026-08-29", 2),
+        }, Ref);
+        Assert.Equal(S6SpecResolveStatus.Matched, r.Status);
+        Assert.Equal(302, r.MatchedSpecId); // 同 wjbh 取 sxrj 最新版本
+        Assert.Equal(1, r.CandidateCount);
+    }
+
+    [Fact]
+    public void InvalidSpec_MatchedButNoDetail()
+    {
+        var r = S6ProcessSpecResolver.Decide(new[] { C(401, "UATD-GC-009", 1, "2026-08-28", 0) }, Ref);
+        Assert.Equal(S6SpecResolveStatus.InvalidSpec, r.Status);
+    }
+
+    [Fact]
+    public void NoMatch_OnlyFutureEffective()
+    {
+        var r = S6ProcessSpecResolver.Decide(new[] { C(501, "UATD-GC-010", 1, "2027-01-01", 2) }, Ref);
+        Assert.Equal(S6SpecResolveStatus.NoMatch, r.Status);
+    }
+
+    [Fact]
+    public void NormalizeDate_HandlesFreeTextFormats()
+    {
+        Assert.NotNull(S6ProcessSpecResolver.NormalizeDate("2026-08-28"));
+        Assert.NotNull(S6ProcessSpecResolver.NormalizeDate("2025.12.12"));
+        Assert.NotNull(S6ProcessSpecResolver.NormalizeDate("2025-05-28 00:00:00"));
+        Assert.Null(S6ProcessSpecResolver.NormalizeDate(""));
+        Assert.Null(S6ProcessSpecResolver.NormalizeDate("包装(灭菌类)"));
+    }
+
+    [Fact]
+    public void Tokenize_SplitsMixedDelimiters()
+    {
+        var t = S6ProcessSpecResolver.Tokenize("91HC1N7;91HC1N8,98BC19A、982C01911 91HC12N");
+        Assert.Contains("91HC1N7", t);
+        Assert.Contains("98BC19A", t);
+        Assert.Contains("982C01911", t);
+        Assert.Equal(5, t.Count);
+    }
+}

+ 56 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/Dto/S6ProcessInspectionDto.cs

@@ -0,0 +1,56 @@
+namespace Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+
+/// <summary>prepare 入参:仅工单号;租户/检规均后端解析,前端不得传 tenantId/specId。</summary>
+public class S6ProcessInspectionPrepareInput
+{
+    public string? WorkOrderNo { get; set; }
+}
+
+/// <summary>prepare 结果。只有 Status==MATCHED 才有 BillId。</summary>
+public class S6ProcessInspectionPrepareResult
+{
+    public string Status { get; set; } = string.Empty;
+    public string Message { get; set; } = string.Empty;
+    public long? BillId { get; set; }
+    /// <summary>是否复用既有 active 检验单(幂等)。</summary>
+    public bool Reused { get; set; }
+    public string? SpecCode { get; set; }
+    public string? SpecVersion { get; set; }
+    public int CandidateCount { get; set; }
+}
+
+/// <summary>过程检验单详情(只读:单据 + 快照头 + 快照明细)。</summary>
+public class S6ProcessInspectionDetailDto
+{
+    public long BillId { get; set; }
+    public string? WorkOrderNo { get; set; }
+    public string? LotSerial { get; set; }
+    public string? ItemCode { get; set; }
+    public string? ItemName { get; set; }
+    public string? Specification { get; set; }
+    public long? SpecId { get; set; }
+    public string? SpecCode { get; set; }
+    public string? SpecVersion { get; set; }
+    public string? EffectiveDate { get; set; }
+    public string? InspectionStatus { get; set; }
+    public string? CreatedByName { get; set; }
+    public DateTime? CreateTime { get; set; }
+    public List<S6ProcessInspectionSnapshotLineDto> Lines { get; set; } = new();
+}
+
+/// <summary>快照明细行(只读,Phase 1B 冻结自 S0 检规明细)。</summary>
+public class S6ProcessInspectionSnapshotLineDto
+{
+    public long Id { get; set; }
+    public string? InspectionItem { get; set; }
+    public string? ProcessCode { get; set; }
+    public string? ProcessName { get; set; }
+    public string? Method { get; set; }
+    public string? SpecValue { get; set; }
+    public string? TechStandard { get; set; }
+    public string? LowerLimit { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? InspectionFrequency { get; set; }
+    public string? ResultType { get; set; }
+    public int? SortNo { get; set; }
+}

+ 37 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/Dto/S6ProcessSpecResolveDto.cs

@@ -0,0 +1,37 @@
+namespace Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+
+/// <summary>过程检规解析状态。</summary>
+public static class S6SpecResolveStatus
+{
+    public const string Matched = "MATCHED";
+    public const string NoMatch = "NO_MATCH";
+    public const string Ambiguous = "AMBIGUOUS";
+    public const string InvalidSpec = "INVALID_SPEC";
+}
+
+/// <summary>
+/// 过程检规自动解析结果(Phase 1B)。只有 status==MATCHED 且 CandidateCount==1 才允许生成检验单。
+/// </summary>
+public class S6ProcessSpecResolveResult
+{
+    public string Status { get; set; } = S6SpecResolveStatus.NoMatch;
+    public long? MatchedSpecId { get; set; }
+    public string? SpecCode { get; set; }
+    public string? SpecVersion { get; set; }
+    public string? EffectiveDate { get; set; }
+    /// <summary>有效 wjbh 分组数(决策计数):MATCHED==1;AMBIGUOUS&gt;=2。</summary>
+    public int CandidateCount { get; set; }
+    public string Message { get; set; } = string.Empty;
+}
+
+/// <summary>过程检规候选(裁决用,一行=一张命中检规)。SELECT 列名映射 Id/Wjbh/Bb/Version/Sxrj/DetailCount。</summary>
+public sealed class S6SpecCandidate
+{
+    public long Id { get; set; }
+    public string? Wjbh { get; set; }
+    public string? Bb { get; set; }
+    public int? Version { get; set; }
+    public string? Sxrj { get; set; }
+    public int DetailCount { get; set; }
+    public DateTime? NormalizedEffective { get; set; }
+}

+ 214 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6ProcessInspectionService.cs

@@ -0,0 +1,214 @@
+using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 过程检验单 服务(Phase 1B):生产指令 → 自动唯一解析过程检规 → 生成检验单 + 检规快照 → 只读详情。
+///
+/// prepare:入参仅 workOrderNo。后端自解析 CurrentTenant(AidopTenantScope)+ 加载生产指令(mdp_std_work_order_schedule)
+///   → S6ProcessSpecResolver 按 (tenant, item_code, order_date) 唯一解析。
+///   仅 MATCHED 才幂等生成/复用检验单 + 冻结检规快照(同一事务);NO_MATCH/AMBIGUOUS/INVALID 一律不写库、原样返回状态。
+/// 无人工检规选择;全程 tenant 显式过滤;快照生成后详情只读快照,不再回查 S0。
+/// FACTORY_SCOPE = PARTIAL(S0 检规无 factory_id,安全边界为 Tenant;factory 仅随单据承载不做过滤)。
+/// </summary>
+[ApiDescriptionSettings(Order = 304, Description = "过程检验单")]
+[Route("api/S6ProcessInspection")]
+[NonUnify]
+public class S6ProcessInspectionService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly S6ProcessSpecResolver _resolver;
+
+    public S6ProcessInspectionService(ISqlSugarClient db, UserManager userManager, S6ProcessSpecResolver resolver)
+    {
+        _db = db;
+        _userManager = userManager;
+        _resolver = resolver;
+    }
+
+    private long ResolveTenantOrThrow() => Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+
+    /// <summary>
+    /// 准备过程检验单:解析检规,MATCHED 则幂等生成/复用检验单 + 快照,返回 billId + status。
+    /// </summary>
+    [DisplayName("准备过程检验单")]
+    [HttpPost("prepare")]
+    public async Task<S6ProcessInspectionPrepareResult> Prepare([FromBody] S6ProcessInspectionPrepareInput input)
+    {
+        var res = new S6ProcessInspectionPrepareResult();
+        if (input == null || string.IsNullOrWhiteSpace(input.WorkOrderNo))
+        {
+            res.Status = S6SpecResolveStatus.NoMatch;
+            res.Message = "缺少工单编号。";
+            return res;
+        }
+
+        var tid = ResolveTenantOrThrow();
+        var wo = input.WorkOrderNo.Trim();
+
+        // 加载生产指令(严格租户;跨租户/不存在 → 明确拒绝,不泄漏)
+        var pi = await _db.Ado.SqlQuerySingleAsync<ProdInstrRow>(
+            """
+            SELECT work_order AS WorkOrder, lot_serial AS LotSerial, item_code AS ItemCode, item_name AS ItemName,
+                   specification AS Specification, factory_id AS FactoryId, order_date AS OrderDate
+            FROM mdp_std_work_order_schedule
+            WHERE tenant_id=@TenantId AND work_order=@Wo LIMIT 1
+            """,
+            new List<SugarParameter> { new("@TenantId", tid), new("@Wo", wo) });
+        if (pi == null)
+        {
+            res.Status = S6SpecResolveStatus.NoMatch;
+            res.Message = "未找到该工单的生产指令。";
+            return res;
+        }
+
+        // ReferenceDate 取检验时点(now):过程检验在当前执行,应采用"检验时点当前有效"的检规;
+        // 生产指令 OrderDate 是下单时点,可能早于检规生效日(sxrj),用它会漏掉当前有效检规。(§9 明确说明)
+        var refDate = DateTime.Now;
+        var resolve = await _resolver.ResolveAsync(tid, pi.ItemCode, refDate);
+        res.Status = resolve.Status;
+        res.Message = resolve.Message;
+        res.SpecCode = resolve.SpecCode;
+        res.SpecVersion = resolve.SpecVersion;
+        res.CandidateCount = resolve.CandidateCount;
+
+        if (resolve.Status != S6SpecResolveStatus.Matched || resolve.CandidateCount != 1 || resolve.MatchedSpecId is not { } specId)
+            return res; // 非唯一解析:不写库
+
+        // 幂等:已有 active(DRAFT/ACTIVE) 检验单 → 复用
+        var existingId = await _db.Ado.SqlQuerySingleAsync<long?>(
+            """
+            SELECT id FROM ado_s6_process_inspection_bill
+            WHERE tenant_id=@TenantId AND work_order_no=@Wo AND inspection_status IN ('DRAFT','ACTIVE')
+            ORDER BY id DESC LIMIT 1
+            """,
+            new List<SugarParameter> { new("@TenantId", tid), new("@Wo", wo) });
+        if (existingId is { } eid && eid > 0)
+        {
+            res.BillId = eid;
+            res.Reused = true;
+            return res;
+        }
+
+        // 生成:检验单 + 快照头 + 快照明细(同一事务,任一失败全回滚)
+        var uid = _userManager.UserId;
+        var uname = _userManager.RealName ?? _userManager.Account;
+        try
+        {
+            _db.Ado.BeginTran();
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_s6_process_inspection_bill
+                (tenant_id, factory_id, work_order_no, lot_serial, item_code, item_name, specification,
+                 spec_id, spec_code, spec_version, inspection_status, created_by, created_by_name)
+                VALUES (@TenantId, @FactoryId, @Wo, @Lot, @ItemCode, @ItemName, @Spec,
+                        @SpecId, @SpecCode, @SpecVersion, 'DRAFT', @Uid, @Uname)
+                """,
+                new List<SugarParameter>
+                {
+                    new("@TenantId", tid),
+                    new("@FactoryId", (object?)pi.FactoryId ?? DBNull.Value),
+                    new("@Wo", wo),
+                    new("@Lot", (object?)pi.LotSerial ?? DBNull.Value),
+                    new("@ItemCode", (object?)pi.ItemCode ?? DBNull.Value),
+                    new("@ItemName", (object?)pi.ItemName ?? DBNull.Value),
+                    new("@Spec", (object?)pi.Specification ?? DBNull.Value),
+                    new("@SpecId", specId),
+                    new("@SpecCode", (object?)resolve.SpecCode ?? DBNull.Value),
+                    new("@SpecVersion", (object?)resolve.SpecVersion ?? DBNull.Value),
+                    new("@Uid", (object?)uid ?? DBNull.Value),
+                    new("@Uname", (object?)uname ?? DBNull.Value),
+                });
+
+            var billId = await _db.Ado.GetLongAsync("SELECT LAST_INSERT_ID()");
+
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_s6_process_inspection_snapshot_head
+                (tenant_id, bill_id, spec_id, spec_code, spec_version, effective_date, item_code, specification, source_tenant_id)
+                VALUES (@TenantId, @BillId, @SpecId, @SpecCode, @SpecVersion, @Eff, @ItemCode, @Spec, @TenantId)
+                """,
+                new List<SugarParameter>
+                {
+                    new("@TenantId", tid),
+                    new("@BillId", billId),
+                    new("@SpecId", specId),
+                    new("@SpecCode", (object?)resolve.SpecCode ?? DBNull.Value),
+                    new("@SpecVersion", (object?)resolve.SpecVersion ?? DBNull.Value),
+                    new("@Eff", (object?)resolve.EffectiveDate ?? DBNull.Value),
+                    new("@ItemCode", (object?)pi.ItemCode ?? DBNull.Value),
+                    new("@Spec", (object?)pi.Specification ?? DBNull.Value),
+                });
+
+            var lineCount = await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_s6_process_inspection_snapshot_line
+                (tenant_id, bill_id, source_line_id, inspection_item, process_code, process_name, method,
+                 spec_value, tech_standard, lower_limit, upper_limit, inspection_frequency, result_type, sort_no)
+                SELECT @TenantId, @BillId, z.id, z.jyxm, z.gxdh, z.gxmc, z.jyff,
+                       z.jygg, z.jsbz, z.xx, z.sx, z.jypc, z.lrlx, NULL
+                FROM qms_gcjygfzb z
+                WHERE z.tenant_id=@TenantId AND z.glid=@SpecId
+                """,
+                new List<SugarParameter> { new("@TenantId", tid), new("@BillId", billId), new("@SpecId", specId) });
+
+            if (lineCount <= 0)
+                throw Oops.Oh("过程检验规范无有效明细,无法生成检验单。");
+
+            _db.Ado.CommitTran();
+            res.BillId = billId;
+            res.Reused = false;
+            return res;
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+    }
+
+    /// <summary>过程检验单只读详情(单据 + 快照头 + 快照明细)。跨租户/不存在返回 null。</summary>
+    [DisplayName("过程检验单详情")]
+    [HttpGet("detail")]
+    public async Task<S6ProcessInspectionDetailDto?> GetDetail([FromQuery] long billId)
+    {
+        if (billId <= 0) return null;
+        var tid = ResolveTenantOrThrow();
+        var pars = new List<SugarParameter> { new("@TenantId", tid), new("@BillId", billId) };
+
+        var dto = await _db.Ado.SqlQuerySingleAsync<S6ProcessInspectionDetailDto>(
+            """
+            SELECT b.id AS BillId, b.work_order_no AS WorkOrderNo, b.lot_serial AS LotSerial, b.item_code AS ItemCode,
+                   b.item_name AS ItemName, b.specification AS Specification, b.spec_id AS SpecId, b.spec_code AS SpecCode,
+                   b.spec_version AS SpecVersion, h.effective_date AS EffectiveDate, b.inspection_status AS InspectionStatus,
+                   b.created_by_name AS CreatedByName, b.create_time AS CreateTime
+            FROM ado_s6_process_inspection_bill b
+            LEFT JOIN ado_s6_process_inspection_snapshot_head h ON h.bill_id=b.id AND h.tenant_id=@TenantId
+            WHERE b.tenant_id=@TenantId AND b.id=@BillId LIMIT 1
+            """, pars);
+        if (dto == null) return null;
+
+        dto.Lines = await _db.Ado.SqlQueryAsync<S6ProcessInspectionSnapshotLineDto>(
+            """
+            SELECT id AS Id, inspection_item AS InspectionItem, process_code AS ProcessCode, process_name AS ProcessName,
+                   method AS Method, spec_value AS SpecValue, tech_standard AS TechStandard, lower_limit AS LowerLimit,
+                   upper_limit AS UpperLimit, inspection_frequency AS InspectionFrequency, result_type AS ResultType, sort_no AS SortNo
+            FROM ado_s6_process_inspection_snapshot_line
+            WHERE tenant_id=@TenantId AND bill_id=@BillId ORDER BY id
+            """, pars);
+        return dto;
+    }
+
+    private sealed class ProdInstrRow
+    {
+        public string? WorkOrder { get; set; }
+        public string? LotSerial { get; set; }
+        public string? ItemCode { get; set; }
+        public string? ItemName { get; set; }
+        public string? Specification { get; set; }
+        public long? FactoryId { get; set; }
+        public DateTime? OrderDate { get; set; }
+    }
+}

+ 120 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6ProcessSpecResolver.cs

@@ -0,0 +1,120 @@
+using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+using System.Globalization;
+
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 过程检规 自动唯一解析器(Phase 1B)。
+///
+/// 输入 (CurrentTenant, ItemCode, ReferenceDate) → 唯一有效过程检规 或 明确的不可解析原因。
+/// 运行期只查规范映射表 ado_s6_ipqc_spec_material_map(不再 split wlbm blob)+ S0 检规头/明细。
+/// 裁决规则(wjbh 分组,见 <see cref="Decide"/>):
+///   候选 = map(tenant,item,EXACT) ⋈ qms_gcjygf(同租户),取有效者(sxrj 可解析、sxrj &lt;= ReferenceDate、明细行数 &gt; 0);
+///   按 wjbh(文件编号)分组,组内取 sxrj 最新(并列取 version 最大)为有效版本;
+///   恰好 1 个 wjbh 组有效 → MATCHED;≥2 个 → AMBIGUOUS;
+///   有候选但均无明细/日期非法 → INVALID_SPEC;无候选/仅未来生效 → NO_MATCH。
+/// 全程 tenant 显式过滤,绝不跨租户。禁止人工选择、禁止 id desc 兜底。
+/// </summary>
+public class S6ProcessSpecResolver : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public S6ProcessSpecResolver(ISqlSugarClient db) => _db = db;
+
+    public async Task<S6ProcessSpecResolveResult> ResolveAsync(long tenantId, string? itemCode, DateTime referenceDate)
+    {
+        if (tenantId <= 0 || string.IsNullOrWhiteSpace(itemCode))
+            return new S6ProcessSpecResolveResult { Status = S6SpecResolveStatus.NoMatch, Message = "缺少物料编码,无法解析过程检验规范。" };
+
+        var candidates = await _db.Ado.SqlQueryAsync<S6SpecCandidate>(
+            """
+            SELECT g.id AS Id, g.wjbh AS Wjbh, g.bb AS Bb, g.version AS Version, g.sxrj AS Sxrj,
+                   (SELECT COUNT(1) FROM qms_gcjygfzb z WHERE z.tenant_id=@TenantId AND z.glid=g.id) AS DetailCount
+            FROM ado_s6_ipqc_spec_material_map m
+            JOIN qms_gcjygf g ON g.id=m.spec_id AND g.tenant_id=@TenantId
+            WHERE m.tenant_id=@TenantId AND m.item_code=@ItemCode AND m.match_status='EXACT'
+            """,
+            new List<SugarParameter> { new("@TenantId", tenantId), new("@ItemCode", itemCode.Trim()) });
+
+        return Decide(candidates, referenceDate);
+    }
+
+    /// <summary>
+    /// 纯裁决逻辑(无 DB,便于单测)。candidates 为已按 (tenant,item,EXACT) 命中的检规候选。
+    /// </summary>
+    public static S6ProcessSpecResolveResult Decide(IReadOnlyList<S6SpecCandidate>? candidates, DateTime referenceDate)
+    {
+        var result = new S6ProcessSpecResolveResult();
+        if (candidates == null || candidates.Count == 0)
+        {
+            result.Status = S6SpecResolveStatus.NoMatch;
+            result.Message = "当前物料未配置有效过程检验规范,请维护 S0 过程检验规范。";
+            return result;
+        }
+
+        foreach (var c in candidates) c.NormalizedEffective = NormalizeDate(c.Sxrj);
+
+        var effective = candidates
+            .Where(c => c.NormalizedEffective != null && c.NormalizedEffective.Value.Date <= referenceDate.Date && c.DetailCount > 0)
+            .ToList();
+        var structurallyInvalid = candidates.Any(c => c.DetailCount <= 0 || c.NormalizedEffective == null);
+
+        if (effective.Count > 0)
+        {
+            var groups = effective.GroupBy(c => (c.Wjbh ?? string.Empty).Trim()).ToList();
+            if (groups.Count == 1)
+            {
+                var winner = groups[0]
+                    .OrderByDescending(c => c.NormalizedEffective)
+                    .ThenByDescending(c => c.Version ?? 0)
+                    .ThenByDescending(c => c.Id)
+                    .First();
+                result.Status = S6SpecResolveStatus.Matched;
+                result.MatchedSpecId = winner.Id;
+                result.SpecCode = winner.Wjbh;
+                result.SpecVersion = winner.Bb;
+                result.EffectiveDate = winner.Sxrj;
+                result.CandidateCount = 1;
+                return result;
+            }
+
+            result.Status = S6SpecResolveStatus.Ambiguous;
+            result.CandidateCount = groups.Count;
+            result.Message = "当前物料存在多个同时有效过程检验规范,请修正 S0 规范配置。";
+            return result;
+        }
+
+        if (structurallyInvalid)
+        {
+            result.Status = S6SpecResolveStatus.InvalidSpec;
+            result.Message = "过程检验规范配置无效,请检查版本/生效日期/明细。";
+            return result;
+        }
+
+        result.Status = S6SpecResolveStatus.NoMatch;
+        result.Message = "当前物料未配置有效过程检验规范,请维护 S0 过程检验规范。";
+        return result;
+    }
+
+    /// <summary>
+    /// 归一化 S0 sxrj(生效日期)自由文本为日期。支持 2026-08-28 / 2025.12.12 / 2025-05-28 00:00:00 等;不可解析返回 null。
+    /// </summary>
+    public static DateTime? NormalizeDate(string? raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw)) return null;
+        var s = raw.Trim().Replace('.', '-').Replace('/', '-');
+        var sp = s.IndexOf(' ');
+        if (sp > 0) s = s[..sp];
+        if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) return d;
+        if (DateTime.TryParseExact(s, new[] { "yyyy-M-d", "yyyy-MM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out d)) return d;
+        return null;
+    }
+
+    /// <summary>tokenize 多值 wlbm(英/中分号、逗号、空白、换行)→ 规范化片段(建立映射用,非运行期)。</summary>
+    public static IReadOnlyList<string> Tokenize(string? wlbm)
+    {
+        if (string.IsNullOrWhiteSpace(wlbm)) return Array.Empty<string>();
+        var parts = wlbm.Split(new[] { ';', ';', ',', ',', '、', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+        return parts.Select(p => p.Trim().ToUpperInvariant()).Where(p => p.Length > 0).Distinct().ToList();
+    }
+}