Bladeren bron

feat(s7): FQC 成品检验全链路(1A-1F) 报检→生成→录入→提交→审批流闭环

- 报检(引用完工工单)/生成检验单(检规快照)/检验录入(数值自动判定只读快照+非数值数量语义)/成品检规判定维护
- 审批流复用 ApprovalFlow:检验员 N1→主管 N2→网关(pd)→合格结束 / 不合格→N3 QE处置;租户 797 审批人绑定(1.0.361)
- 修复①检规物料映射 rebuild 的 datetime 空串(SqlSugar 字典批插→参数化批插)②FQC std 贴源 AIDOP orphan 清理(RunFullAsync 后按 source_system=AIDOPDEV_MYSQL 对活源清孤儿)
- 迁移 1.0.358-361(避让共享库已占 357);版本 server 1.0.361 / Web 2.4.299
YY968XX 1 maand geleden
bovenliggende
commit
6260942895

+ 1 - 1
Web/package.json

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

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

@@ -225,6 +225,9 @@ const FUNC_DEFS: FuncDef[] = [
 	{ code: 'FUNC-S7-005', name: '发货任务看板', paths: ['/aidop/s7/finished-warehouse-kanban/shipment-task'], names: ['aidopS7FinishedWarehouseKanbanShipmentTask'] },
 	{ code: 'FUNC-S7-006', name: '成品滞销看板', paths: ['/aidop/s7/finished-warehouse-kanban/sluggish'], names: ['aidopS7FinishedWarehouseKanbanSluggish'] },
 	{ code: 'FUNC-S7-007', name: 'S7成品仓储看板', paths: ['/aidop/smart-ops/s7', '/aidop/kanban/s7'], names: ['aidopSmartOpsS7'] },
+	{ code: 'FUNC-S7-008', name: '成品检规判定维护', paths: ['/aidop/s7/fqc/spec-rule'], names: ['aidopS7FqcSpecRule'] },
+	{ code: 'FUNC-S7-009', name: '成品报检', paths: ['/aidop/s7/fqc/apply'], names: ['aidopS7FqcApply'] },
+	{ code: 'FUNC-S7-010', name: '检验单录入', paths: ['/aidop/s7/fqc/insp-detail'], names: ['aidopS7FqcInspDetail'] },
 
 	// ── S8 异常监控(扩展编号)──
 	{ code: 'FUNC-S8-001', name: '异常监控看板', paths: ['/aidop/s8/dashboard', '/aidop/smart-ops/s8', '/aidop/kanban/s8'], names: ['aidopS8Dashboard', 'aidopSmartOpsS8'] },

+ 67 - 0
Web/src/views/aidop/s7/api/fqcApply.ts

@@ -0,0 +1,67 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '/@/views/aidop/api/aidopTenant';
+
+// ── S7 FQC 成品报检(Phase 1C):选完工工单→回填→保存 qms_fqcbj ──
+
+export interface FqcWorkOrderRow {
+	recId: number;
+	batch?: string;
+	workOrd?: string;
+	materialCode?: string;
+	productName?: string;
+	productModel?: string;
+	lotSerial?: string;
+	qtyOrded?: number;
+	qtyCompleted?: number;
+	status?: string;
+}
+
+export interface FqcApplyRow {
+	id: number;
+	billNo?: string;
+	materialCode?: string;
+	productName?: string;
+	productModel?: string;
+	productionBatchNo?: string;
+	productionOrderNo?: string;
+	orderQty?: number;
+	qty?: number;
+	priority?: string;
+	fireNo?: string;
+	applicant?: string;
+	applyTime?: string;
+	inspectStatus?: string;
+	remark?: string;
+}
+
+interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+/** 完工工单候选(引用生产指令单弹窗) */
+export function fetchFqcWorkOrders(params: Record<string, unknown>) {
+	return service.get<Paged<FqcWorkOrderRow>>('/api/S7FqcApply/work-orders', { params: withAidopTenantParams(params) }).then((r: any) => r.data as Paged<FqcWorkOrderRow>);
+}
+
+/** 成品报检列表 */
+export function fetchFqcApplyPage(params: Record<string, unknown>) {
+	return service.get<Paged<FqcApplyRow>>('/api/S7FqcApply/page', { params: withAidopTenantParams(params) }).then((r: any) => r.data as Paged<FqcApplyRow>);
+}
+
+/** 成品报检详情 */
+export function fetchFqcApplyDetail(id: number) {
+	return service.get<FqcApplyRow>('/api/S7FqcApply/detail', { params: withAidopTenantParams({ id }) }).then((r: any) => r.data as FqcApplyRow);
+}
+
+/** 从完工工单创建报检 */
+export function createFqcApply(payload: { workOrdRecId: number; qty?: number | null; priority?: string | null; fireNo?: string | null; remark?: string | null }) {
+	return service.post('/api/S7FqcApply/create', payload).then((r: any) => r.data);
+}
+
+/** 生成检验单(幂等:已生成返回 created=false + 已有单号) */
+export function generateFqcInspection(payload: { applyId: number; specId?: number | null }) {
+	return service.post<{ created: boolean; id: number; billNo: string }>('/api/S7FqcApply/generate-inspection', payload).then((r: any) => r.data as { created: boolean; id: number; billNo: string });
+}

+ 54 - 0
Web/src/views/aidop/s7/api/fqcInspection.ts

@@ -0,0 +1,54 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '/@/views/aidop/api/aidopTenant';
+
+// ── S7 FQC 检验录入(Phase 1E):活单 qms_qcpp_inspbill/st 录入 + 自动判定 ──
+
+export interface FqcInspHead {
+	id: number;
+	billNo?: string;
+	sourceBillNo?: string;
+	materialName?: string;
+	spec?: string;
+	productionBatchNo?: string;
+	productionOrderNo?: string;
+	specNo?: string;
+	specVersion?: string;
+	pd?: string;
+	qualifiedQty?: number | null;
+	unqualifiedQty?: number | null;
+	billStatus?: string;
+}
+
+export interface FqcInspLine {
+	id: number;
+	xh?: number;
+	jyxm?: string;
+	/** 录入类型快照 NUMERIC / NON_NUMERIC / '' */
+	lrlx?: string;
+	sx?: string;
+	xx?: string;
+	jsyq?: string;
+	jsbz?: string;
+	cyfa?: string;
+	ybl?: string;
+	pd?: string;
+	nonNumericalTypeOK?: number | null;
+	nonNumericalTypeNG?: number | null;
+	/** j1..j50 样本值 */
+	samples?: (string | null)[];
+}
+
+/** 检验录入详情(活单) */
+export function fetchInspectionDetail(inspBillId: number) {
+	return service.get<{ head: FqcInspHead; lines: FqcInspLine[] }>('/api/S7FqcInspection/detail', { params: withAidopTenantParams({ inspBillId }) }).then((r: any) => r.data as { head: FqcInspHead; lines: FqcInspLine[] });
+}
+
+/** 保存录入(草稿,不起流) */
+export function saveInspectionDetail(payload: { inspBillId: number; lines: Array<{ id: number; samples?: (string | null)[]; nonNumericalTypeOK?: number | null; nonNumericalTypeNG?: number | null; ybl?: string | null; pd?: string | null }> }) {
+	return service.post('/api/S7FqcInspection/save-detail', payload).then((r: any) => r.data);
+}
+
+/** 自动判定(只读快照 sx/xx,NUMERIC 逐样本判;非数值/无规则留人工) */
+export function autoJudgeInspection(inspBillId: number) {
+	return service.post<{ judged: number; pending: number; message: string }>('/api/S7FqcInspection/auto-judge', { inspBillId }).then((r: any) => r.data as { judged: number; pending: number; message: string });
+}

+ 65 - 0
Web/src/views/aidop/s7/api/fqcSpecRule.ts

@@ -0,0 +1,65 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '/@/views/aidop/api/aidopTenant';
+
+// ── S7 FQC 检规结构化判定维护(Phase 1B,语义纠偏版)──
+// 录入类型 lrlx:NUMERIC 数值型(维护上下限 UpperLimit/LowerLimit → 快照 inspbillst.sx/xx,检验录 j1..j50)
+//                NON_NUMERIC 非数值型(不维护上下限,检验只录合格数/不合格数 → inspbillst.NonNumericalTypeOK/NG 为检验结果数量,非检规字段)
+// jsyq(技术要求) 为人读原文,不解析。
+
+export interface FqcSpecHeadRow {
+	id: number;
+	wjbh?: string;
+	bb?: string;
+	cpmc?: string;
+	cpxh?: string;
+	sxrq?: string;
+}
+
+export interface FqcSpecRuleRow {
+	id: number;
+	xh?: string;
+	jyxm?: string;
+	jsyq?: string;
+	jsbz?: string;
+	cyfa?: string;
+	/** 录入类型 NUMERIC / NON_NUMERIC / null */
+	lrlx?: string | null;
+	upperLimit?: string | null;
+	lowerLimit?: string | null;
+	/** NUMERIC / NON_NUMERIC / NONE(后端推导) */
+	judgmentType?: string;
+}
+
+export interface FqcSpecPaged {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: FqcSpecHeadRow[];
+}
+
+/** 检规主表列表 */
+export function fetchFqcSpecPage(params: { wjbh?: string; cpmc?: string; page: number; pageSize: number }) {
+	return service.get<FqcSpecPaged>('/api/S7FqcSpecRule/spec-page', { params: withAidopTenantParams(params) }).then((r: any) => r.data as FqcSpecPaged);
+}
+
+/** 某检规的检验项目明细 + 判定条件 */
+export function fetchFqcSpecRuleList(jygfid: number) {
+	return service
+		.get<{ jygfid: number; list: FqcSpecRuleRow[] }>('/api/S7FqcSpecRule/detail-list', { params: withAidopTenantParams({ jygfid }) })
+		.then((r: any) => (r.data?.list ?? []) as FqcSpecRuleRow[]);
+}
+
+/** 保存单条明细判定条件(录入类型 + 数值上下限) */
+export function saveFqcSpecRule(payload: {
+	id: number;
+	lrlx?: string | null;
+	upperLimit?: string | null;
+	lowerLimit?: string | null;
+}) {
+	return service.post('/api/S7FqcSpecRule/save-rule', payload).then((r: any) => r.data);
+}
+
+/** 清空单条明细判定条件(→ 未配置自动判定) */
+export function clearFqcSpecRule(id: number) {
+	return service.post('/api/S7FqcSpecRule/clear-rule', { id }).then((r: any) => r.data);
+}

+ 275 - 0
Web/src/views/aidop/s7/fqc/fqcApplyList.vue

@@ -0,0 +1,275 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="成品报检:引用完工工单 → 回填 → 保存(保存后触发 FQC 同步进检验任务)">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="单据编号"><el-input v-model="query.billNo" clearable style="width: 170px" /></el-form-item>
+			<el-form-item label="物料编码"><el-input v-model="query.materialCode" clearable style="width: 150px" /></el-form-item>
+			<el-form-item label="生产批号"><el-input v-model="query.productionBatchNo" clearable style="width: 140px" /></el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button type="success" @click="openCreate">新增报检</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" v-loading="loading" border stripe>
+			<el-table-column prop="billNo" label="报检单号" min-width="160" show-overflow-tooltip />
+			<el-table-column prop="materialCode" label="物料编码" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="productName" label="产品名称" min-width="140" show-overflow-tooltip />
+			<el-table-column prop="productModel" label="产品型号" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="productionBatchNo" label="生产批号" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="productionOrderNo" label="生产指令单" min-width="120" show-overflow-tooltip />
+			<el-table-column prop="orderQty" label="指令单数量" min-width="100" align="right" />
+			<el-table-column prop="qty" label="报检数量" min-width="90" align="right" />
+			<el-table-column prop="inspectStatus" label="检验进度" min-width="90" />
+			<el-table-column prop="applicant" label="申请人" min-width="100" show-overflow-tooltip />
+			<el-table-column label="操作" width="230" fixed="right">
+				<template #default="{ row }">
+					<el-button type="primary" link @click="openDetail(row)">查看</el-button>
+					<el-button v-if="!row.inspBillId" type="success" link :loading="genId === row.id" @click="onGenerate(row)">生成检验单</el-button>
+					<el-button v-else type="warning" link @click="openInspection(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-dialog v-model="createVisible" title="新增成品报检" width="640px">
+			<el-form label-width="110px">
+				<el-form-item label="生产指令单">
+					<el-input :model-value="form.batch" readonly placeholder="点右侧「引用工单」选择">
+						<template #append><el-button @click="openWoPick">引用工单</el-button></template>
+					</el-input>
+				</el-form-item>
+				<el-descriptions v-if="form.workOrdRecId" :column="2" border size="small" class="mb12">
+					<el-descriptions-item label="物料编码">{{ form.materialCode || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="产品名称">{{ form.productName || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="产品型号">{{ form.productModel || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="生产批号">{{ form.lotSerial || '-' }}</el-descriptions-item>
+					<el-descriptions-item label="指令单数量">{{ form.qtyOrded ?? '-' }}</el-descriptions-item>
+					<el-descriptions-item label="已完成数量">{{ form.qtyCompleted ?? '-' }}</el-descriptions-item>
+				</el-descriptions>
+				<el-form-item label="报检数量"><el-input-number v-model="form.qty" :controls="false" :min="0" style="width: 180px" /></el-form-item>
+				<el-form-item label="检验优先级"><el-input v-model="form.priority" style="width: 180px" /></el-form-item>
+				<el-form-item label="击发编号"><el-input v-model="form.fireNo" style="width: 260px" /></el-form-item>
+				<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" style="width: 360px" /></el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="createVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" :disabled="!form.workOrdRecId" @click="onCreate">保存</el-button>
+			</template>
+		</el-dialog>
+
+		<!-- 引用完工工单 -->
+		<el-dialog v-model="woVisible" title="引用生产指令单(完工工单)" width="900px">
+			<el-form :inline="true" :model="woQuery" class="mb8" @submit.prevent>
+				<el-form-item label="工单号"><el-input v-model="woQuery.batch" clearable style="width: 130px" /></el-form-item>
+				<el-form-item label="物料"><el-input v-model="woQuery.materialCode" clearable style="width: 130px" /></el-form-item>
+				<el-form-item><el-button type="primary" @click="doWoSearch">查询</el-button></el-form-item>
+			</el-form>
+			<el-table :data="woRows" v-loading="woLoading" border stripe size="small" height="380" @row-dblclick="pickWo">
+				<el-table-column prop="batch" label="工单号" min-width="110" show-overflow-tooltip />
+				<el-table-column prop="materialCode" label="物料" min-width="100" show-overflow-tooltip />
+				<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
+				<el-table-column prop="lotSerial" label="批号" min-width="100" show-overflow-tooltip />
+				<el-table-column label="完工情况" min-width="150">
+					<template #default="{ row }">
+						{{ row.qtyCompleted ?? 0 }} / {{ row.qtyOrded ?? 0 }}
+						<span v-if="woHint(row)" class="hint">({{ woHint(row) }})</span>
+					</template>
+				</el-table-column>
+				<el-table-column prop="status" label="状态" width="70" />
+				<el-table-column label="操作" width="70" fixed="right">
+					<template #default="{ row }"><el-button type="primary" link size="small" @click="pickWo(row)">选择</el-button></template>
+				</el-table-column>
+				<template #empty><el-empty description="无完工工单候选" /></template>
+			</el-table>
+			<div class="pager">
+				<el-pagination v-model:current-page="woQuery.page" v-model:page-size="woQuery.pageSize" :total="woTotal" layout="total, prev, pager, next" @current-change="loadWo" />
+			</div>
+		</el-dialog>
+
+		<!-- 详情 -->
+		<el-dialog v-model="detailVisible" title="成品报检详情" width="600px">
+			<el-descriptions v-if="currentDetail" :column="2" border size="small">
+				<el-descriptions-item label="报检单号">{{ currentDetail.billNo }}</el-descriptions-item>
+				<el-descriptions-item label="检验进度">{{ currentDetail.inspectStatus }}</el-descriptions-item>
+				<el-descriptions-item label="物料编码">{{ currentDetail.materialCode }}</el-descriptions-item>
+				<el-descriptions-item label="产品名称">{{ currentDetail.productName }}</el-descriptions-item>
+				<el-descriptions-item label="产品型号">{{ currentDetail.productModel }}</el-descriptions-item>
+				<el-descriptions-item label="生产批号">{{ currentDetail.productionBatchNo }}</el-descriptions-item>
+				<el-descriptions-item label="生产指令单">{{ currentDetail.productionOrderNo }}</el-descriptions-item>
+				<el-descriptions-item label="指令单数量">{{ currentDetail.orderQty }}</el-descriptions-item>
+				<el-descriptions-item label="报检数量">{{ currentDetail.qty }}</el-descriptions-item>
+				<el-descriptions-item label="检验优先级">{{ currentDetail.priority }}</el-descriptions-item>
+				<el-descriptions-item label="申请人">{{ currentDetail.applicant }}</el-descriptions-item>
+				<el-descriptions-item label="备注">{{ currentDetail.remark }}</el-descriptions-item>
+			</el-descriptions>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS7FqcApplyList">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchFqcWorkOrders, fetchFqcApplyPage, fetchFqcApplyDetail, createFqcApply, generateFqcInspection, type FqcWorkOrderRow, type FqcApplyRow } from '../api/fqcApply';
+
+const route = useRoute();
+const router = useRouter();
+const pageTitle = computed(() => (route.meta?.title as string) || '成品报检');
+
+// 进入检验录入(Phase 1E,独立隐藏路由 tab)
+function openInspection(row: FqcApplyRow & { inspBillId?: number }) {
+	if (!row.inspBillId) return;
+	router.push({ path: '/aidop/s7/fqc/insp-detail', query: { id: String(row.inspBillId) } });
+}
+
+const query = reactive({ billNo: '', materialCode: '', productionBatchNo: '', page: 1, pageSize: 10 });
+const rows = ref<FqcApplyRow[]>([]);
+const total = ref(0);
+const loading = ref(false);
+const genId = ref<number | null>(null); // 生成中的行 id(前端防双击,非幂等防线——真正幂等在 Service+DB UNIQUE)
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const d = await fetchFqcApplyPage({ ...query });
+		rows.value = d.list || [];
+		total.value = d.total || 0;
+	} catch (e: any) {
+		rows.value = [];
+		ElMessage.error(e?.message || '加载报检列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+// 新增报检
+const createVisible = ref(false);
+const saving = ref(false);
+const form = reactive<{ workOrdRecId: number | null; batch: string; materialCode: string; productName: string; productModel: string; lotSerial: string; qtyOrded: number | null; qtyCompleted: number | null; qty: number | null; priority: string; fireNo: string; remark: string }>({
+	workOrdRecId: null, batch: '', materialCode: '', productName: '', productModel: '', lotSerial: '', qtyOrded: null, qtyCompleted: null, qty: null, priority: '', fireNo: '', remark: '',
+});
+function openCreate() {
+	Object.assign(form, { workOrdRecId: null, batch: '', materialCode: '', productName: '', productModel: '', lotSerial: '', qtyOrded: null, qtyCompleted: null, qty: null, priority: '', fireNo: '', remark: '' });
+	createVisible.value = true;
+}
+async function onCreate() {
+	if (!form.workOrdRecId) {
+		ElMessage.warning('请先引用一张生产指令单');
+		return;
+	}
+	saving.value = true;
+	try {
+		await createFqcApply({ workOrdRecId: form.workOrdRecId, qty: form.qty, priority: form.priority || null, fireNo: form.fireNo || null, remark: form.remark || null });
+		ElMessage.success('报检已保存');
+		createVisible.value = false;
+		loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+// 引用工单
+const woVisible = ref(false);
+const woQuery = reactive({ batch: '', materialCode: '', page: 1, pageSize: 10 });
+const woRows = ref<FqcWorkOrderRow[]>([]);
+const woTotal = ref(0);
+const woLoading = ref(false);
+function openWoPick() {
+	woVisible.value = true;
+	loadWo();
+}
+async function loadWo() {
+	woLoading.value = true;
+	try {
+		const d = await fetchFqcWorkOrders({ ...woQuery });
+		woRows.value = d.list || [];
+		woTotal.value = d.total || 0;
+	} catch (e: any) {
+		woRows.value = [];
+		ElMessage.error(e?.message || '加载工单候选失败');
+	} finally {
+		woLoading.value = false;
+	}
+}
+function doWoSearch() {
+	woQuery.page = 1;
+	loadWo();
+}
+function woHint(row: FqcWorkOrderRow): string {
+	const oc = Number(row.qtyCompleted ?? 0), od = Number(row.qtyOrded ?? 0);
+	if (oc === 0) return '当前完工数量为 0,请确认工单状态';
+	if (od > 0 && oc < od) return '未完全完工,请确认是否报检';
+	return '';
+}
+function pickWo(row: FqcWorkOrderRow) {
+	form.workOrdRecId = row.recId;
+	form.batch = row.batch || '';
+	form.materialCode = row.materialCode || '';
+	form.productName = row.productName || '';
+	form.productModel = row.productModel || '';
+	form.lotSerial = row.lotSerial || '';
+	form.qtyOrded = row.qtyOrded ?? null;
+	form.qtyCompleted = row.qtyCompleted ?? null;
+	woVisible.value = false;
+}
+
+// 生成检验单(Phase 1D,仅生成+提示,不做录入 1E)
+async function onGenerate(row: FqcApplyRow) {
+	if (!row?.id || genId.value) return;
+	genId.value = row.id;
+	try {
+		const res = await generateFqcInspection({ applyId: row.id });
+		if (res.created) ElMessage.success(`已生成检验单 ${res.billNo}`);
+		else ElMessage.info(`该报检已生成检验单 ${res.billNo}`);
+		loadList();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '生成检验单失败');
+	} finally {
+		genId.value = null;
+	}
+}
+
+// 详情
+const detailVisible = ref(false);
+const currentDetail = ref<FqcApplyRow | null>(null);
+async function openDetail(row: FqcApplyRow) {
+	if (!row?.id) return;
+	try {
+		currentDetail.value = await fetchFqcApplyDetail(row.id);
+		detailVisible.value = true;
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载详情失败');
+	}
+}
+
+onMounted(loadList);
+</script>
+
+<style scoped lang="scss">
+.mb8 {
+	margin-bottom: 8px;
+}
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 8px;
+	display: flex;
+	justify-content: flex-end;
+}
+.hint {
+	color: #e6a23c;
+	font-size: 12px;
+}
+</style>

+ 246 - 0
Web/src/views/aidop/s7/fqc/fqcInspDetail.vue

@@ -0,0 +1,246 @@
+<template>
+	<div class="fqc-insp-detail">
+		<el-page-header @back="goBack">
+			<template #content>
+				<span>检验单录入 {{ head?.billNo || '' }}</span>
+				<el-tag v-if="head?.billStatus" size="small" class="ml8">{{ head.billStatus }}</el-tag>
+			</template>
+		</el-page-header>
+
+		<el-descriptions v-if="head" :column="3" border size="small" class="mt12">
+			<el-descriptions-item label="检验单号">{{ head.billNo }}</el-descriptions-item>
+			<el-descriptions-item label="来源报检">{{ head.sourceBillNo }}</el-descriptions-item>
+			<el-descriptions-item label="检规">{{ head.specNo }} / {{ head.specVersion }}</el-descriptions-item>
+			<el-descriptions-item label="产品名称">{{ head.materialName }}</el-descriptions-item>
+			<el-descriptions-item label="规格型号">{{ head.spec }}</el-descriptions-item>
+			<el-descriptions-item label="生产批号">{{ head.productionBatchNo }}</el-descriptions-item>
+		</el-descriptions>
+
+		<div class="toolbar mt12">
+			<el-button type="warning" :loading="judging" @click="onAutoJudge">自动判定</el-button>
+			<el-button :loading="saving" @click="onSave">保存</el-button>
+			<el-button type="primary" @click="openSubmit">提交</el-button>
+			<span class="tip">数值型展开录样本值,自动判定按快照上下限判;非数值型录合格/不合格数,无规则由人工确认判定。</span>
+		</div>
+
+		<el-table :data="lines" v-loading="loading" border stripe size="small" row-key="id" class="mt12">
+			<el-table-column type="expand">
+				<template #default="{ row }">
+					<div class="expand" v-if="row.lrlx === 'NUMERIC'">
+						<span class="lbl">样本值(上限 {{ row.sx || '-' }} / 下限 {{ row.xx || '-' }}):</span>
+						<el-input v-for="(s, i) in sampleSlots(row)" :key="i" v-model="row.samples[i]" size="small" class="sample" :placeholder="'样本' + (i + 1)" />
+						<el-button link type="primary" size="small" @click="addSample(row)">+样本</el-button>
+					</div>
+					<div class="expand" v-else>非数值型项目:请在行内填「合格数 / 不合格数」,无需逐样本录入。</div>
+				</template>
+			</el-table-column>
+			<el-table-column prop="xh" label="序号" width="56" />
+			<el-table-column prop="jyxm" label="检验项目" min-width="130" show-overflow-tooltip />
+			<el-table-column label="类型" width="82">
+				<template #default="{ row }">
+					<el-tag v-if="row.lrlx === 'NUMERIC'" type="success" size="small">数值</el-tag>
+					<el-tag v-else-if="row.lrlx === 'NON_NUMERIC'" type="warning" size="small">非数值</el-tag>
+					<span v-else class="muted">未定</span>
+				</template>
+			</el-table-column>
+			<el-table-column prop="jsyq" label="技术要求" min-width="140" show-overflow-tooltip />
+			<el-table-column label="合格数" width="90">
+				<template #default="{ row }">
+					<el-input-number v-if="row.lrlx === 'NON_NUMERIC'" v-model="row.nonNumericalTypeOK" :controls="false" :min="0" size="small" style="width: 78px" />
+					<span v-else class="muted">—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="不合格数" width="90">
+				<template #default="{ row }">
+					<el-input-number v-if="row.lrlx === 'NON_NUMERIC'" v-model="row.nonNumericalTypeNG" :controls="false" :min="0" size="small" style="width: 78px" />
+					<span v-else class="muted">—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="判定" width="120">
+				<template #default="{ row }">
+					<el-select v-model="row.pd" size="small" placeholder="待判定" clearable style="width: 100px">
+						<el-option label="合格" value="合格" />
+						<el-option label="不合格" value="不合格" />
+					</el-select>
+				</template>
+			</el-table-column>
+			<template #empty><el-empty description="该检验单无明细" /></template>
+		</el-table>
+
+		<!-- 提交(整单判定 + 合格/不合格数) -->
+		<el-dialog v-model="submitVisible" title="提交检验结果" width="440px">
+			<el-form label-width="110px">
+				<el-form-item label="整单判定">
+					<el-select v-model="submitForm.pd" style="width: 180px">
+						<el-option :value="0" label="合格" />
+						<el-option :value="1" label="不合格" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="合格数量"><el-input-number v-model="submitForm.hgsl" :controls="false" :min="0" style="width: 180px" /></el-form-item>
+				<el-form-item label="不合格数量"><el-input-number v-model="submitForm.bhgsl" :controls="false" :min="0" :disabled="submitForm.pd === 0" style="width: 180px" /></el-form-item>
+				<el-form-item label="处理方式" v-if="submitForm.pd === 1">
+					<el-select v-model="submitForm.clfs" style="width: 180px" clearable>
+						<el-option :value="0" label="让步接收" />
+						<el-option :value="1" label="挑选" />
+						<el-option :value="2" label="报废" />
+					</el-select>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="submitVisible = false">取消</el-button>
+				<el-button type="primary" :loading="submitting" @click="onSubmit">确认提交</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopS7FqcInspDetail">
+import { onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import mittBus from '/@/utils/mitt';
+import { fetchInspectionDetail, saveInspectionDetail, autoJudgeInspection, type FqcInspHead, type FqcInspLine } from '../api/fqcInspection';
+import { submitFqcResult } from '../api/fqcInspBillFlow';
+
+const route = useRoute();
+const router = useRouter();
+const inspBillId = ref<number>(Number(route.query.id) || 0);
+
+const head = ref<FqcInspHead | null>(null);
+const lines = ref<FqcInspLine[]>([]);
+const loading = ref(false);
+const saving = ref(false);
+const judging = ref(false);
+
+function sampleSlots(row: FqcInspLine): number {
+	if (!row.samples) row.samples = [];
+	// 至少 5 个空槽,或已录入数+1
+	const filled = row.samples.filter((s) => s != null && String(s).trim() !== '').length;
+	const n = Math.max(5, filled + 1);
+	while (row.samples.length < n) row.samples.push(null);
+	return row.samples.length;
+}
+function addSample(row: FqcInspLine) {
+	if (!row.samples) row.samples = [];
+	row.samples.push(null);
+}
+
+async function load() {
+	if (!inspBillId.value) {
+		ElMessage.error('缺少检验单 id');
+		return;
+	}
+	loading.value = true;
+	try {
+		const d = await fetchInspectionDetail(inspBillId.value);
+		head.value = d.head;
+		lines.value = (d.lines || []).map((l) => ({ ...l, samples: l.samples ? [...l.samples] : [] }));
+	} catch (e: any) {
+		ElMessage.error(e?.message || '加载检验单失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function onSave() {
+	saving.value = true;
+	try {
+		await saveInspectionDetail({
+			inspBillId: inspBillId.value,
+			lines: lines.value.map((l) => ({ id: l.id, samples: l.samples, nonNumericalTypeOK: l.nonNumericalTypeOK ?? null, nonNumericalTypeNG: l.nonNumericalTypeNG ?? null, ybl: l.ybl ?? null, pd: l.pd ?? null })),
+		});
+		ElMessage.success('已保存');
+	} catch (e: any) {
+		ElMessage.error(e?.message || '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function onAutoJudge() {
+	judging.value = true;
+	try {
+		await onSave(); // 先存样本再判
+		const res = await autoJudgeInspection(inspBillId.value);
+		ElMessage.success(res.message);
+		await load();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '自动判定失败');
+	} finally {
+		judging.value = false;
+	}
+}
+
+// 提交
+const submitVisible = ref(false);
+const submitting = ref(false);
+const submitForm = reactive<{ pd: number; hgsl: number; bhgsl: number; clfs: number | null }>({ pd: 0, hgsl: 0, bhgsl: 0, clfs: null });
+function openSubmit() {
+	submitForm.pd = 0;
+	submitForm.hgsl = 0;
+	submitForm.bhgsl = 0;
+	submitForm.clfs = null;
+	submitVisible.value = true;
+}
+async function onSubmit() {
+	if (submitForm.pd === 0 && submitForm.bhgsl > 0) {
+		ElMessage.error('合格时不合格数量应为 0');
+		return;
+	}
+	if (submitForm.pd === 1 && submitForm.bhgsl <= 0) {
+		ElMessage.error('不合格时不合格数量需大于 0');
+		return;
+	}
+	submitting.value = true;
+	try {
+		await onSave();
+		await submitFqcResult({ id: inspBillId.value, pd: submitForm.pd, hgsl: submitForm.hgsl, bhgsl: submitForm.bhgsl, clfs: submitForm.pd === 1 ? submitForm.clfs : null });
+		ElMessage.success('已提交,转检验主管审核');
+		submitVisible.value = false;
+		goBack();
+	} catch (e: any) {
+		ElMessage.error(e?.message || '提交失败');
+	} finally {
+		submitting.value = false;
+	}
+}
+
+function goBack() {
+	router.push({ path: '/aidop/s7/fqc/apply' });
+	// 关闭当前 tab(复用框架 tagsView 关闭机制)
+	mittBus.emit('onCurrentContextmenuClick', { contextMenuClickId: 1, ...route });
+}
+
+onMounted(load);
+</script>
+
+<style scoped lang="scss">
+.fqc-insp-detail {
+	padding: 12px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.ml8 {
+	margin-left: 8px;
+}
+.toolbar .tip {
+	color: #909399;
+	font-size: 12px;
+	margin-left: 12px;
+}
+.expand {
+	padding: 8px 16px;
+	.lbl {
+		margin-right: 8px;
+		color: #606266;
+	}
+	.sample {
+		width: 90px;
+		margin-right: 6px;
+	}
+}
+.muted {
+	color: #c0c4cc;
+}
+</style>

+ 222 - 0
Web/src/views/aidop/s7/fqc/fqcSpecRuleList.vue

@@ -0,0 +1,222 @@
+<template>
+	<AidopDemoShell :title="pageTitle" subtitle="成品检规结构化判定维护:录入类型(数值型/非数值型) + 数值上下限(jsyq 为人读原文,不解析)">
+		<el-row :gutter="12">
+			<!-- 左:检规主表 -->
+			<el-col :span="9">
+				<el-form :inline="true" :model="specQuery" class="mb8" @submit.prevent>
+					<el-form-item label="文件编号">
+						<el-input v-model="specQuery.wjbh" clearable style="width: 130px" />
+					</el-form-item>
+					<el-form-item label="产品名称">
+						<el-input v-model="specQuery.cpmc" clearable style="width: 130px" />
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="doSpecSearch">查询</el-button>
+					</el-form-item>
+				</el-form>
+				<el-table :data="specRows" v-loading="specLoading" border stripe highlight-current-row height="560" @current-change="onSpecPick">
+					<el-table-column prop="wjbh" label="文件编号" min-width="120" show-overflow-tooltip />
+					<el-table-column prop="bb" label="版本" width="70" />
+					<el-table-column prop="cpmc" label="产品名称" min-width="130" show-overflow-tooltip />
+					<template #empty><el-empty description="暂无检规" /></template>
+				</el-table>
+				<div class="pager">
+					<el-pagination v-model:current-page="specQuery.page" v-model:page-size="specQuery.pageSize" :total="specTotal" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next" @current-change="loadSpecs" @size-change="loadSpecs" />
+				</div>
+			</el-col>
+
+			<!-- 右:检验项目明细判定条件 -->
+			<el-col :span="15">
+				<div class="hint" v-if="!currentSpecId">← 请先在左侧选择一条检规</div>
+				<template v-else>
+					<div class="mb8">
+						<b>{{ currentSpecName }}</b>
+						<span class="muted">(共 {{ ruleRows.length }} 个检验项目)</span>
+					</div>
+					<el-table :data="ruleRows" v-loading="ruleLoading" border stripe size="small" height="560">
+						<el-table-column prop="xh" label="序号" width="56" />
+						<el-table-column prop="jyxm" label="检验项目" min-width="120" show-overflow-tooltip />
+						<el-table-column prop="jsyq" label="技术要求(人读)" min-width="150" show-overflow-tooltip />
+						<el-table-column label="录入类型" width="130">
+							<template #default="{ row }">
+								<el-select v-model="row.lrlx" size="small" placeholder="未设置" clearable style="width: 112px" @change="onTypeChange(row)">
+									<el-option label="数值型" value="NUMERIC" />
+									<el-option label="非数值型" value="NON_NUMERIC" />
+								</el-select>
+							</template>
+						</el-table-column>
+						<el-table-column label="下限" width="100">
+							<template #default="{ row }">
+								<el-input v-if="row.lrlx !== 'NON_NUMERIC'" v-model="row.lowerLimit" size="small" placeholder="下限" />
+								<span v-else class="muted">—</span>
+							</template>
+						</el-table-column>
+						<el-table-column label="上限" width="100">
+							<template #default="{ row }">
+								<el-input v-if="row.lrlx !== 'NON_NUMERIC'" v-model="row.upperLimit" size="small" placeholder="上限" />
+								<span v-else class="muted">—</span>
+							</template>
+						</el-table-column>
+						<el-table-column label="判定方式" width="88">
+							<template #default="{ row }">
+								<el-tag v-if="deriveType(row) === 'NUMERIC'" type="success" size="small">数值</el-tag>
+								<el-tag v-else-if="deriveType(row) === 'NON_NUMERIC'" type="warning" size="small">非数值</el-tag>
+								<span v-else class="muted">未配置</span>
+							</template>
+						</el-table-column>
+						<el-table-column label="操作" width="120" fixed="right">
+							<template #default="{ row }">
+								<el-button type="primary" link size="small" @click="onSave(row)">保存</el-button>
+								<el-button type="info" link size="small" @click="onClear(row)">清空</el-button>
+							</template>
+						</el-table-column>
+						<template #empty><el-empty description="该检规无检验项目明细" /></template>
+					</el-table>
+					<div class="tip">
+						数值型:填下限/上限(下限≤上限),检验时按样本录 j1..j50;非数值型:只标类型,检验时填合格数/不合格数(不在检规填)。留空=未配置。
+					</div>
+				</template>
+			</el-col>
+		</el-row>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS7FqcSpecRuleList">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchFqcSpecPage, fetchFqcSpecRuleList, saveFqcSpecRule, clearFqcSpecRule, type FqcSpecHeadRow, type FqcSpecRuleRow } from '../api/fqcSpecRule';
+
+const route = useRoute();
+const pageTitle = computed(() => (route.meta?.title as string) || '成品检规判定维护');
+
+const specQuery = reactive({ wjbh: '', cpmc: '', page: 1, pageSize: 20 });
+const specRows = ref<FqcSpecHeadRow[]>([]);
+const specTotal = ref(0);
+const specLoading = ref(false);
+
+const currentSpecId = ref<number | null>(null);
+const currentSpecName = ref('');
+const ruleRows = ref<FqcSpecRuleRow[]>([]);
+const ruleLoading = ref(false);
+
+function isNum(v: unknown): boolean {
+	return v !== null && v !== undefined && String(v).trim() !== '';
+}
+function deriveType(row: FqcSpecRuleRow): string {
+	if (row.lrlx === 'NUMERIC') return 'NUMERIC';
+	if (row.lrlx === 'NON_NUMERIC') return 'NON_NUMERIC';
+	return isNum(row.upperLimit) || isNum(row.lowerLimit) ? 'NUMERIC' : 'NONE';
+}
+
+// 切到非数值型时清空上下限(前端即时,与后端一致)
+function onTypeChange(row: FqcSpecRuleRow) {
+	if (row.lrlx === 'NON_NUMERIC') {
+		row.upperLimit = null;
+		row.lowerLimit = null;
+	}
+}
+
+async function loadSpecs() {
+	specLoading.value = true;
+	try {
+		const data = await fetchFqcSpecPage({ ...specQuery });
+		specRows.value = data.list || [];
+		specTotal.value = data.total || 0;
+	} catch (e: any) {
+		specRows.value = [];
+		ElMessage.error(e?.message || '加载检规失败');
+	} finally {
+		specLoading.value = false;
+	}
+}
+function doSpecSearch() {
+	specQuery.page = 1;
+	loadSpecs();
+}
+
+async function onSpecPick(row: FqcSpecHeadRow | null) {
+	if (!row?.id) return;
+	currentSpecId.value = row.id;
+	currentSpecName.value = `${row.wjbh || ''} ${row.bb || ''}  ${row.cpmc || ''}`.trim();
+	ruleLoading.value = true;
+	try {
+		ruleRows.value = await fetchFqcSpecRuleList(row.id);
+	} catch (e: any) {
+		ruleRows.value = [];
+		ElMessage.error(e?.message || '加载检规明细失败');
+	} finally {
+		ruleLoading.value = false;
+	}
+}
+
+async function onSave(row: FqcSpecRuleRow) {
+	// 数值型上下限校验(非数值型不校验上下限)
+	if (row.lrlx !== 'NON_NUMERIC' && isNum(row.upperLimit) && isNum(row.lowerLimit)) {
+		const lo = Number(row.lowerLimit), up = Number(row.upperLimit);
+		if (Number.isNaN(lo) || Number.isNaN(up)) {
+			ElMessage.error('上/下限必须是数值');
+			return;
+		}
+		if (lo > up) {
+			ElMessage.error('下限不能大于上限');
+			return;
+		}
+	}
+	try {
+		const res = await saveFqcSpecRule({
+			id: row.id,
+			lrlx: row.lrlx || null,
+			upperLimit: row.lrlx === 'NON_NUMERIC' ? null : isNum(row.upperLimit) ? String(row.upperLimit).trim() : null,
+			lowerLimit: row.lrlx === 'NON_NUMERIC' ? null : isNum(row.lowerLimit) ? String(row.lowerLimit).trim() : null,
+		});
+		row.judgmentType = res?.judgmentType || deriveType(row);
+		ElMessage.success('已保存');
+	} catch (e: any) {
+		ElMessage.error(e?.message || '保存失败');
+	}
+}
+
+async function onClear(row: FqcSpecRuleRow) {
+	try {
+		await ElMessageBox.confirm('确认清空该项目的录入类型与判定条件?', '提示');
+		await clearFqcSpecRule(row.id);
+		row.lrlx = null;
+		row.upperLimit = null;
+		row.lowerLimit = null;
+		row.judgmentType = 'NONE';
+		ElMessage.success('已清空');
+	} catch (e: any) {
+		if (e !== 'cancel') ElMessage.error(e?.message || '清空失败');
+	}
+}
+
+onMounted(loadSpecs);
+</script>
+
+<style scoped lang="scss">
+.mb8 {
+	margin-bottom: 8px;
+}
+.pager {
+	margin-top: 8px;
+	display: flex;
+	justify-content: flex-end;
+}
+.hint {
+	color: #909399;
+	padding: 40px 0;
+	text-align: center;
+}
+.muted {
+	color: #909399;
+	font-size: 12px;
+	margin-left: 6px;
+}
+.tip {
+	margin-top: 8px;
+	color: #e6a23c;
+	font-size: 12px;
+}
+</style>

+ 15 - 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.356</AssemblyVersion>
-    <FileVersion>1.0.356</FileVersion>
-    <Version>1.0.356</Version>
+    <AssemblyVersion>1.0.361</AssemblyVersion>
+    <FileVersion>1.0.361</FileVersion>
+    <Version>1.0.361</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -439,6 +439,18 @@
     <None Update="UpdateScripts\1.0.356.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.358.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.359.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.360.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.361.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 63 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.358.sql

@@ -0,0 +1,63 @@
+-- ============================================================================
+-- S7 FQC 成品检验全链路 · 阶段 1A · P0 基础设施迁移
+-- 内容:① 报检/检验单幂等 UNIQUE(G3) ② SQLSERVER 入站实体 status→0 切源护栏(P0)
+--       ③ 检规↔物料映射桥表(G1,不改原始 qms_ccjygf.wlbm)
+-- 幂等:全部 IF-NOT-EXISTS 守卫,可重复执行。
+-- 迁移前已实测三处 UNIQUE 目标 0 冲突、桥表不存在。
+-- 占位名:提交那刻 rename 为正式 1.0.<next>.sql 并登记 csproj Copy。
+-- ============================================================================
+
+-- ---- ① qms_fqcbj: 报检单号租户内唯一 ----
+SET @exist := (SELECT COUNT(1) FROM information_schema.statistics
+  WHERE table_schema=DATABASE() AND table_name='qms_fqcbj' AND index_name='uk_qms_fqcbj_tenant_billno');
+SET @sql := IF(@exist=0,
+  'ALTER TABLE qms_fqcbj ADD UNIQUE KEY uk_qms_fqcbj_tenant_billno (tenant_id, FBILLNO)',
+  'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---- ② qms_qcpp_inspbill: 检验单号租户内唯一 ----
+SET @exist := (SELECT COUNT(1) FROM information_schema.statistics
+  WHERE table_schema=DATABASE() AND table_name='qms_qcpp_inspbill' AND index_name='uk_qms_qcpp_inspbill_tenant_billno');
+SET @sql := IF(@exist=0,
+  'ALTER TABLE qms_qcpp_inspbill ADD UNIQUE KEY uk_qms_qcpp_inspbill_tenant_billno (tenant_id, FBILLNO)',
+  'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---- ③ qms_qcpp_inspbill: 一个报检(lydjbh=报检FBILLNO, hid=报检id)只生成一张检验单(G3 DB层幂等) ----
+SET @exist := (SELECT COUNT(1) FROM information_schema.statistics
+  WHERE table_schema=DATABASE() AND table_name='qms_qcpp_inspbill' AND index_name='uk_qms_qcpp_inspbill_source');
+SET @sql := IF(@exist=0,
+  'ALTER TABLE qms_qcpp_inspbill ADD UNIQUE KEY uk_qms_qcpp_inspbill_source (tenant_id, lydjbh, hid)',
+  'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---- ④ 切源护栏:FQC 三个 SQLSERVER 入站实体 status→0(就位不启用),防 RunSourceSwitchFull 覆盖 DOP 写入 ----
+UPDATE mdp_entity SET status=0
+ WHERE entity_code IN ('S7_FQC_TASK_SQLSERVER','S7_FQC_RESULT_SQLSERVER','S7_FQC_RESULT_DETAIL_SQLSERVER')
+   AND status<>0;
+
+-- ---- ⑤ 检规↔标准物料 派生映射桥表(G1)。不改原始 qms_ccjygf.wlbm,此表为派生+人工订正层 ----
+CREATE TABLE IF NOT EXISTS ado_s7_fqc_spec_material_map (
+  id                        BIGINT       NOT NULL COMMENT '主键(雪花)',
+  tenant_id                 BIGINT       NOT NULL COMMENT '租户Id',
+  jygfid                    BIGINT       NOT NULL COMMENT '检规主表 qms_ccjygf.id',
+  wjbh                      VARCHAR(100) NULL     COMMENT '检规文件编号(冗余便于查)',
+  bb                        VARCHAR(50)  NULL     COMMENT '检规版本',
+  seq                       INT          NULL     COMMENT 'wlbm 内序号',
+  raw_token                 VARCHAR(200) NULL     COMMENT 'wlbm 拆出的原始 token',
+  cleaned_token             VARCHAR(200) NULL     COMMENT '分隔符归一后 token',
+  material_code             VARCHAR(100) NULL     COMMENT '匹配到的 ItemMaster.ItemNum(EXACT/MANUAL 时有值)',
+  match_status              VARCHAR(30)  NOT NULL DEFAULT 'NOT_FOUND' COMMENT 'EXACT/RANGE_EXPANDED/GLUE_UNRESOLVED/NOT_FOUND/MANUAL',
+  normalized_effective_date DATETIME     NULL     COMMENT '规范化生效日期(源 sxrq 清洗,原值不改)',
+  note                      VARCHAR(500) NULL     COMMENT '备注/unresolved 原因',
+  is_manual                 TINYINT      NOT NULL DEFAULT 0 COMMENT '1=人工维护绑定',
+  status                    TINYINT      NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
+  create_time               DATETIME     NULL     COMMENT '创建时间',
+  create_user_id            BIGINT       NULL     COMMENT '创建人',
+  update_time               DATETIME     NULL     COMMENT '更新时间',
+  update_user_id            BIGINT       NULL     COMMENT '更新人',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_fqc_spec_map (tenant_id, jygfid, raw_token, is_manual),
+  KEY idx_fqc_spec_map_mat (tenant_id, material_code, status),
+  KEY idx_fqc_spec_map_jygf (tenant_id, jygfid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S7 FQC 检规↔标准物料 派生映射桥(不改源 wlbm)';

+ 33 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.359.sql

@@ -0,0 +1,33 @@
+-- ============================================================================
+-- S7 FQC 成品检验全链路 · 阶段 1B · 检规"录入类型"识别字段(语义纠偏后)
+--
+-- 【语义纠偏】NonNumericalTypeOK/NG 不是检规规则字段,而是"某一次检验中非数值项目的
+--   实际合格数量/不合格数量",属检验单明细 qms_qcpp_inspbillst,绝不放检规模板 qms_ccjygfzb。
+--   → 撤销前一版误加在 qms_ccjygfzb 的 NonNumericalTypeOK/NG(实测两列全 NULL、无业务数据、仅本任务代码消费,安全 DROP)。
+--
+-- 检规模板真正缺的是"数值型/非数值型"识别依据(情况 C:现有无类型字段 + Upper/Lower 100% 空不能推导)。
+--   → 新增 lrlx(录入类型):NUMERIC 数值型(维护上下限, 检验录 j1..j50) / NON_NUMERIC 非数值型(检验只录合格/不合格数)。
+--
+-- 目标表 qms_qcpp_inspbillst.NonNumericalTypeOK/NG(BIGINT) 保留不动 = 检验单本次实际合格/不合格数量。
+-- 幂等:IF-EXISTS/IF-NOT-EXISTS 守卫,可重复执行。不改任何历史数据、不改 jsyq/Upper/Lower 原值。
+-- 占位名:提交那刻 rename 为正式 1.0.<next>.sql 并登记 csproj Copy。
+-- ============================================================================
+
+-- ① 撤销误加的检规结果数量字段(属检验单,不属检规模板)
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns
+  WHERE table_schema=DATABASE() AND table_name='qms_ccjygfzb' AND COLUMN_NAME='NonNumericalTypeOK');
+SET @sql := IF(@exist=1, 'ALTER TABLE qms_ccjygfzb DROP COLUMN NonNumericalTypeOK', 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns
+  WHERE table_schema=DATABASE() AND table_name='qms_ccjygfzb' AND COLUMN_NAME='NonNumericalTypeNG');
+SET @sql := IF(@exist=1, 'ALTER TABLE qms_ccjygfzb DROP COLUMN NonNumericalTypeNG', 'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ② 新增录入类型识别字段(情况 C:无现成类型字段,需显式标注数值/非数值)
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns
+  WHERE table_schema=DATABASE() AND table_name='qms_ccjygfzb' AND COLUMN_NAME='lrlx');
+SET @sql := IF(@exist=0,
+  "ALTER TABLE qms_ccjygfzb ADD COLUMN lrlx VARCHAR(20) NULL COMMENT '录入类型:NUMERIC数值型/NON_NUMERIC非数值型(NULL=未设置)'",
+  'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

+ 16 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.360.sql

@@ -0,0 +1,16 @@
+-- ============================================================================
+-- S7 FQC 成品检验全链路 · 阶段 1D · 检验单明细「录入类型」快照承载列
+--
+-- 生成检验单时须把检规 qms_ccjygfzb.lrlx(NUMERIC/NON_NUMERIC) 固化进检验单明细,
+-- 使 Phase 1E 打开历史检验单时按快照决定录入方式(数值逐样本 / 非数值合格·不合格数),
+-- 绝不回查检规。inspbillst 现无 lrlx 承载列 → 补一个同名同型 VARCHAR(20)。
+--
+-- 幂等:IF-NOT-EXISTS 守卫。不改历史数据。占位名:提交那刻 rename 为正式 1.0.<next>.sql 并登记 csproj Copy。
+-- ============================================================================
+
+SET @exist := (SELECT COUNT(1) FROM information_schema.columns
+  WHERE table_schema=DATABASE() AND table_name='qms_qcpp_inspbillst' AND COLUMN_NAME='lrlx');
+SET @sql := IF(@exist=0,
+  "ALTER TABLE qms_qcpp_inspbillst ADD COLUMN lrlx VARCHAR(20) NULL COMMENT '录入类型快照:NUMERIC数值型/NON_NUMERIC非数值型(生成时从检规 ccjygfzb.lrlx 固化,不回查)'",
+  'SELECT 1');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

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

@@ -0,0 +1,35 @@
+-- ============================================================================
+-- S7 FQC 成品检验全链路 · 阶段 1F · 租户 797 审批人补齐(Flow Closure 前置)
+--
+-- 问题:FqcInspBillFlow 三节点 ApproverType=Role(INSPECTOR/SUPERVISOR/QE)。
+--   审批人解析 FlowEngineService.ResolveApprovers:SysUserRole 按 RoleId 取 userIds,
+--   再 SysUser where TenantId==当前登录租户。seed 只绑 superAdmin.NET(TenantId=默认租户),
+--   故租户 797 下三角色成员为空 → StartFlow 抛「节点[检验员检验]未配置审批人」。
+--
+-- 修复:把租户 797 既有 UAT 测试用户 AIDOPDemo(Id=798578309906501, TenantId=797403760988229,
+--   RealName=Demo01) 绑定到三个 FQC 角色 —— 与其已绑的 S5 IQC 三角色(1329915010001~003)同范式。
+--   引擎无 SoD/发起人≠审批人限制,单用户可跨 N1/N2/N3(UAT 自审闭环)。
+--
+-- 只补角色成员、不改 Flow 定义、不改角色本身、不动 superAdmin 既有绑定。
+-- 幂等:INSERT ... WHERE NOT EXISTS,可重复执行。占位名:提交那刻 rename 为正式 1.0.<next>.sql 并登记 csproj Copy。
+-- ============================================================================
+
+SET @uat_user := 798578309906501;      -- AIDOPDemo(租户 797 UAT 测试用户)
+SET @role_inspector  := 1329915020001; -- ROLE_S7_FQC_INSPECTOR
+SET @role_supervisor := 1329915020002; -- ROLE_S7_FQC_SUPERVISOR
+SET @role_qe         := 1329915020003; -- ROLE_S7_FQC_QE
+
+INSERT INTO SysUserRole (Id, UserId, RoleId)
+SELECT 1329915020101, @uat_user, @role_inspector
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM SysUserRole WHERE UserId = @uat_user AND RoleId = @role_inspector);
+
+INSERT INTO SysUserRole (Id, UserId, RoleId)
+SELECT 1329915020102, @uat_user, @role_supervisor
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM SysUserRole WHERE UserId = @uat_user AND RoleId = @role_supervisor);
+
+INSERT INTO SysUserRole (Id, UserId, RoleId)
+SELECT 1329915020103, @uat_user, @role_qe
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM SysUserRole WHERE UserId = @uat_user AND RoleId = @role_qe);

+ 507 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcApplyService.cs

@@ -0,0 +1,507 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 成品报检(Phase 1C)。复刻旧系统"组织实验通知单":选一张(完工)工单 → 回填 → 保存成 qms_fqcbj 报检。
+///
+/// 复用 S5 IqcTaskEntryService 范式:ResolveTenantOrThrow(登录租户,非透传源)、雪花 id、本库取号(FQCBJ+日期+序号,配 1A UNIQUE)、
+/// UseTranAsync、写成功后触发 FQC refresh(解耦,失败不回滚业务)。
+///
+/// 回填可信边界(§十二):wlbm/cpmc/cpxh/scph/sczld/sczldsl 一律由后端按用户选的 WorkOrd + ItemMaster 真实数据重读,
+/// 不信前端提交值;人工字段 sl/yxj/jfbh/FCOMMENT 取前端。
+/// 完工口径(§八):不做无证据硬拦截,候选正常展示 + 完工标记由前端提示;仅缺 ItemNum/关键键 硬拒。
+/// 只读 WorkOrdMaster/ItemMaster,只写 qms_fqcbj。本轮不实现"生成检验单"(Phase 1D)。
+/// </summary>
+[ApiDescriptionSettings(Order = 342, Description = "FQC成品报检")]
+[Route("api/S7FqcApply")]
+[NonUnify]
+public class FqcApplyService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+    private readonly FqcMdpSyncService _fqcMdpSyncService;
+    private readonly FqcSpecMaterialMapService _specMap;
+    private readonly ILogger _logger;
+
+    public FqcApplyService(ISqlSugarClient db, UserManager userManager, FqcMdpSyncService fqcMdpSyncService, FqcSpecMaterialMapService specMap, ILoggerFactory loggerFactory)
+    {
+        _db = db;
+        _userManager = userManager;
+        _fqcMdpSyncService = fqcMdpSyncService;
+        _specMap = specMap;
+        _logger = loggerFactory.CreateLogger(nameof(FqcApplyService));
+    }
+
+    private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    /// <summary>成品完工报检默认业务类型/单据类型(占位,实际字典未接,原样落库)。</summary>
+    private const string FqcBillType = "FQC";
+
+    // ============================ 完工工单候选 ============================
+
+    /// <summary>
+    /// 引用生产指令单:完工工单候选(WorkOrdMaster + ItemMaster 富化,只读,租户隔离)。
+    /// 不做完工硬拦截;返回 qtyOrded/qtyCompleted/status 供前端行内提示。
+    /// </summary>
+    [DisplayName("完工工单候选")]
+    [HttpGet("work-orders")]
+    public async Task<object> WorkOrders([FromQuery] FqcWorkOrderQueryInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : (input.PageSize > 200 ? 200 : input.PageSize);
+        var where = new List<string> { "w.tenant_id=@t", "w.ItemNum IS NOT NULL", "w.ItemNum<>''" };
+        var pars = new List<SugarParameter> { new("@t", tenantId) };
+        void Like(string? v, string col, string p) { if (!string.IsNullOrWhiteSpace(v)) { where.Add($"{col} LIKE @{p}"); pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%")); } }
+        Like(input.Batch, "w.Batch", "Batch");
+        Like(input.WorkOrd, "w.WorkOrd", "WorkOrd");
+        Like(input.MaterialCode, "w.ItemNum", "Mat");
+        Like(input.LotSerial, "w.LotSerial", "Lot");
+        if (!string.IsNullOrWhiteSpace(input.Status)) { where.Add("LOWER(w.Status)=@st"); pars.Add(new SugarParameter("@st", input.Status.Trim().ToLowerInvariant())); }
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM WorkOrdMaster w WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<FqcWorkOrderRow>(
+            $@"SELECT w.RecID AS RecId, w.Batch AS Batch, w.WorkOrd AS WorkOrd, w.ItemNum AS MaterialCode,
+                     i.Descr AS ProductName, i.Descr1 AS ProductModel, w.LotSerial AS LotSerial,
+                     w.QtyOrded AS QtyOrded, w.QtyCompleted AS QtyCompleted, LOWER(w.Status) AS Status
+              FROM WorkOrdMaster w
+              LEFT JOIN ItemMaster i ON i.ItemNum=w.ItemNum AND i.tenant_id=w.tenant_id
+              WHERE {whereSql}
+              ORDER BY w.RecID DESC
+              LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
+        return new { total, page, pageSize, list };
+    }
+
+    // ============================ 报检列表/详情 ============================
+
+    /// <summary>成品报检列表(读 qms_fqcbj 本源,租户隔离)。</summary>
+    [DisplayName("成品报检列表")]
+    [HttpGet("page")]
+    public async Task<object> Page([FromQuery] FqcApplyQueryInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : (input.PageSize > 200 ? 200 : input.PageSize);
+        var where = new List<string> { "tenant_id=@t" };
+        var pars = new List<SugarParameter> { new("@t", tenantId) };
+        void Like(string? v, string col, string p) { if (!string.IsNullOrWhiteSpace(v)) { where.Add($"{col} LIKE @{p}"); pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%")); } }
+        Like(input.BillNo, "FBILLNO", "Bill");
+        Like(input.MaterialCode, "wlbm", "Mat");
+        Like(input.ProductionBatchNo, "scph", "Scph");
+        Like(input.ProductionOrderNo, "sczld", "Sczld");
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_fqcbj WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<FqcApplyRow>(
+            $@"SELECT q.id AS Id, q.FBILLNO AS BillNo, q.wlbm AS MaterialCode, q.cpmc AS ProductName, q.cpxh AS ProductModel,
+                     q.scph AS ProductionBatchNo, q.sczld AS ProductionOrderNo, q.sczldsl AS OrderQty, q.sl AS Qty,
+                     q.yxj AS Priority, q.FAPPLYUSER AS Applicant, q.FAPPLYTIME AS ApplyTime, q.FINSPECTSTATUS AS InspectStatus, q.FCOMMENT AS Remark,
+                     ib.id AS InspBillId, ib.FBILLNO AS InspBillNo
+              FROM qms_fqcbj q
+              LEFT JOIN qms_qcpp_inspbill ib ON ib.tenant_id=q.tenant_id AND ib.lydjbh=q.FBILLNO AND ib.hid=q.id
+              WHERE {whereSql.Replace("tenant_id", "q.tenant_id").Replace("FBILLNO", "q.FBILLNO").Replace("wlbm", "q.wlbm").Replace("scph", "q.scph").Replace("sczld", "q.sczld")}
+              ORDER BY q.FAPPLYTIME DESC, q.id DESC LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>成品报检详情(读 qms_fqcbj 单行)。</summary>
+    [DisplayName("成品报检详情")]
+    [HttpGet("detail")]
+    public async Task<FqcApplyRow?> Detail([FromQuery] long id)
+    {
+        if (id <= 0) return null;
+        var tenantId = ResolveTenantOrThrow();
+        return (await _db.Ado.SqlQueryAsync<FqcApplyRow>(
+            @"SELECT id AS Id, FBILLNO AS BillNo, wlbm AS MaterialCode, cpmc AS ProductName, cpxh AS ProductModel,
+                     scph AS ProductionBatchNo, sczld AS ProductionOrderNo, sczldsl AS OrderQty, sl AS Qty,
+                     yxj AS Priority, jfbh AS FireNo, FAPPLYUSER AS Applicant, FAPPLYTIME AS ApplyTime,
+                     FINSPECTSTATUS AS InspectStatus, FCOMMENT AS Remark
+              FROM qms_fqcbj WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", id), new SugarParameter("@t", tenantId))).FirstOrDefault();
+    }
+
+    // ============================ 创建报检 ============================
+
+    /// <summary>
+    /// 从完工工单创建成品报检。回填字段后端按 WorkOrd+ItemMaster 真实数据重读(防前端篡改),人工字段取前端。
+    /// </summary>
+    [DisplayName("创建成品报检")]
+    [HttpPost("create")]
+    public async Task<object> Create([FromBody] FqcApplyCreateInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.WorkOrdRecId <= 0) throw Oops.Oh("请选择生产指令单(工单)");
+
+        // 后端按用户选的 WorkOrd 重读真实数据(不信前端回填值)
+        var wo = await _db.Ado.SqlQuerySingleAsync<WorkOrdGenRow>(
+            @"SELECT w.RecID AS RecId, w.Batch AS Batch, w.ItemNum AS ItemNum, w.LotSerial AS LotSerial,
+                     w.QtyOrded AS QtyOrded, i.Descr AS ProductName, i.Descr1 AS ProductModel
+              FROM WorkOrdMaster w
+              LEFT JOIN ItemMaster i ON i.ItemNum=w.ItemNum AND i.tenant_id=w.tenant_id
+              WHERE w.RecID=@rid AND w.tenant_id=@t LIMIT 1",
+            new SugarParameter("@rid", input.WorkOrdRecId), new SugarParameter("@t", tenantId));
+        if (wo == null) throw Oops.Oh("工单不存在或不属于当前租户");
+        if (string.IsNullOrWhiteSpace(wo.ItemNum)) throw Oops.Oh("工单缺物料编码,无法报检");
+
+        var now = DateTime.Now;
+        var applyUser = _userManager.RealName ?? _userManager.Account ?? _userManager.UserId.ToString();
+
+        // 取号 + 并发唯一冲突有限重试(配 1A uk_qms_fqcbj_tenant_billno)
+        Exception? lastEx = null;
+        for (var attempt = 0; attempt < 5; attempt++)
+        {
+            var billNo = await AllocateApplyNoAsync(tenantId);
+            var billId = YitIdHelper.NextId();
+            try
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    @"INSERT INTO qms_fqcbj
+                        (id, tenant_id, FBILLNO, FBILLTYPE, FINSPECTSTATUS, FAPPLYUSER, FAPPLYTIME, FORGID, FCREATETIME,
+                         wlbm, cpmc, cpxh, scph, sczld, sczldsl, sl, yxj, jfbh, FCOMMENT)
+                      VALUES
+                        (@id, @t, @no, @btype, '未检验', @user, @now, @org, @now,
+                         @wlbm, @cpmc, @cpxh, @scph, @sczld, @sczldsl, @sl, @yxj, @jfbh, @comment)",
+                    new SugarParameter("@id", billId),
+                    new SugarParameter("@t", tenantId),
+                    new SugarParameter("@no", billNo),
+                    new SugarParameter("@btype", FqcBillType),
+                    new SugarParameter("@user", applyUser),
+                    new SugarParameter("@now", now),
+                    new SugarParameter("@org", (object?)_userManager.OrgId ?? DBNull.Value),
+                    new SugarParameter("@wlbm", wo.ItemNum),
+                    new SugarParameter("@cpmc", (object?)wo.ProductName ?? DBNull.Value),
+                    new SugarParameter("@cpxh", (object?)wo.ProductModel ?? DBNull.Value),
+                    new SugarParameter("@scph", (object?)wo.LotSerial ?? DBNull.Value),
+                    new SugarParameter("@sczld", (object?)wo.Batch ?? DBNull.Value),
+                    new SugarParameter("@sczldsl", (object?)wo.QtyOrded ?? DBNull.Value),
+                    new SugarParameter("@sl", (object?)input.Qty ?? DBNull.Value),
+                    new SugarParameter("@yxj", (object?)NullIfEmpty(input.Priority) ?? DBNull.Value),
+                    new SugarParameter("@jfbh", (object?)NullIfEmpty(input.FireNo) ?? DBNull.Value),
+                    new SugarParameter("@comment", (object?)NullIfEmpty(input.Remark) ?? DBNull.Value));
+
+                await TriggerRefreshSafeAsync();
+                return new { ok = true, id = billId, billNo };
+            }
+            catch (Exception ex) when (IsDupKey(ex))
+            {
+                lastEx = ex; // 号撞了,重取重试
+            }
+        }
+        throw Oops.Oh("报检单号并发冲突,请重试").WithData(lastEx?.Message);
+    }
+
+    // ============================ 生成检验单(Phase 1D) ============================
+
+    /// <summary>
+    /// 报检 → 生成检验单头 + 检规明细快照(同事务)。应用级幂等(lydjbh+hid) + DB UNIQUE(uk_qms_qcpp_inspbill_source) 双保险。
+    /// 检规精确走 Phase 1A 桥表(禁 LIKE);lrlx/上下限固化进 inspbillst;NonNumericalTypeOK/NG 是检验结果数量不从检规带值。
+    /// 本轮不做检验录入/判定/提交(Phase 1E)。
+    /// </summary>
+    [DisplayName("生成检验单")]
+    [HttpPost("generate-inspection")]
+    public async Task<object> GenerateInspection([FromBody] FqcGenerateInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.ApplyId <= 0) throw Oops.Oh("报检 id 非法");
+
+        // 1) 报检存在 + 归属 + 关键字段
+        var apply = await _db.Ado.SqlQuerySingleAsync<ApplyGenRow>(
+            @"SELECT id AS Id, FBILLNO AS BillNo, wlbm AS Wlbm, cpmc AS Cpmc, cpxh AS Cpxh, scph AS Scph, sczld AS Sczld, sczldsl AS Sczldsl
+              FROM qms_fqcbj WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", input.ApplyId), new SugarParameter("@t", tenantId));
+        if (apply == null) throw Oops.Oh("报检不存在或不属于当前租户");
+        if (string.IsNullOrWhiteSpace(apply.BillNo)) throw Oops.Oh("报检单号缺失");
+        if (string.IsNullOrWhiteSpace(apply.Wlbm)) throw Oops.Oh("报检缺物料编码,无法生成检验单");
+
+        // 2) 应用级幂等:一个报检(lydjbh=报检号, hid=报检id)只一张检验单
+        var existing = await QueryExistingInspAsync(tenantId, apply.BillNo!, apply.Id);
+        if (existing != null) return new { created = false, id = existing.Id, billNo = existing.BillNo };
+
+        // 3) 精确解析检规(走桥表,禁 LIKE)
+        var candidates = await _specMap.ResolveInternalAsync(tenantId, apply.Wlbm!);
+        if (candidates.Count == 0) throw Oops.Oh("当前物料未配置成品检规映射,请先维护检规物料映射");
+        var chosen = ChooseSpec(candidates, input.SpecId);
+
+        // 4) 检规明细
+        var details = await _db.Ado.SqlQueryAsync<SpecDetailRow>(
+            @"SELECT id AS Id, xh AS Xh, jyxm AS Jyxm, UpperToleranceLimit AS UpperLimit, LowerToleranceLimit AS LowerLimit,
+                     lrlx AS Lrlx, jsyq AS Jsyq, jyyqsbgj AS Jyyqsbgj, jyff AS Jyff, jytk AS Jytk, jsbz AS Jsbz, cyfa AS Cyfa, jl AS Jl, SizeSpecifications AS SizeSpecifications
+              FROM qms_ccjygfzb WHERE glid=@g AND tenant_id=@t ORDER BY xh, id",
+            new SugarParameter("@g", chosen.Jygfid), new SugarParameter("@t", tenantId));
+        if (details.Count == 0) throw Oops.Oh("当前检规无有效检验项目,不能生成检验单");
+        foreach (var d in details)
+        {
+            if (string.IsNullOrWhiteSpace(d.Jyxm)) throw Oops.Oh("检规存在空检验项目,无法生成检验单");
+            if (d.Lrlx != null && d.Lrlx != "NUMERIC" && d.Lrlx != "NON_NUMERIC") throw Oops.Oh($"检规项目录入类型非法:{d.Jyxm}");
+        }
+
+        var now = DateTime.Now;
+        for (var attempt = 0; attempt < 5; attempt++)
+        {
+            var billNo = await AllocateInspBillNoAsync(tenantId);
+            var headId = YitIdHelper.NextId();
+            var stRows = details.Select(d => new Dictionary<string, object>
+            {
+                ["id"] = YitIdHelper.NextId(),
+                ["tenant_id"] = tenantId,
+                ["glid"] = headId,
+                ["xh"] = (object?)ParseXh(d.Xh) ?? DBNull.Value,
+                ["jyxm"] = (object?)d.Jyxm ?? DBNull.Value,
+                ["sx"] = (object?)d.UpperLimit ?? DBNull.Value,   // 数值上限快照
+                ["xx"] = (object?)d.LowerLimit ?? DBNull.Value,   // 数值下限快照
+                ["lrlx"] = (object?)d.Lrlx ?? DBNull.Value,       // 录入类型快照(NUMERIC/NON_NUMERIC)
+                ["jsyq"] = (object?)d.Jsyq ?? DBNull.Value,
+                ["jyyqsbgj"] = (object?)d.Jyyqsbgj ?? DBNull.Value,
+                ["jyff"] = (object?)d.Jyff ?? DBNull.Value,
+                ["jytk"] = (object?)d.Jytk ?? DBNull.Value,
+                ["jsbz"] = (object?)d.Jsbz ?? DBNull.Value,
+                ["cyfa"] = (object?)d.Cyfa ?? DBNull.Value,
+                ["jl"] = (object?)d.Jl ?? DBNull.Value,
+                ["SizeSpecifications"] = (object?)d.SizeSpecifications ?? DBNull.Value,
+                // NonNumericalTypeOK/NG(检验结果数量) 与 j1..j50/pd 留空,等 Phase 1E 检验员录入;绝不从检规带值
+            }).ToList();
+
+            try
+            {
+                var tran = await _db.Ado.UseTranAsync(async () =>
+                {
+                    await _db.Ado.ExecuteCommandAsync(
+                        @"INSERT INTO qms_qcpp_inspbill
+                            (id, tenant_id, FBILLNO, lydjbh, hid, scph, sczld, sczldsl, wlmc, ggxh,
+                             jgbh, jgbb, jygfid, sxrq, FBILLSTATUS, FCREATETIME)
+                          VALUES
+                            (@id, @t, @no, @ly, @hid, @scph, @sczld, @sczldsl, @wlmc, @ggxh,
+                             @jgbh, @jgbb, @jygfid, @sxrq, '未检验', @now)",
+                        new SugarParameter("@id", headId),
+                        new SugarParameter("@t", tenantId),
+                        new SugarParameter("@no", billNo),
+                        new SugarParameter("@ly", apply.BillNo),
+                        new SugarParameter("@hid", apply.Id),
+                        new SugarParameter("@scph", (object?)apply.Scph ?? DBNull.Value),
+                        new SugarParameter("@sczld", (object?)apply.Sczld ?? DBNull.Value),
+                        new SugarParameter("@sczldsl", (object?)apply.Sczldsl ?? DBNull.Value),
+                        new SugarParameter("@wlmc", (object?)apply.Cpmc ?? DBNull.Value),
+                        new SugarParameter("@ggxh", (object?)apply.Cpxh ?? DBNull.Value),
+                        new SugarParameter("@jgbh", (object?)chosen.Wjbh ?? DBNull.Value),
+                        new SugarParameter("@jgbb", (object?)chosen.Bb ?? DBNull.Value),
+                        new SugarParameter("@jygfid", chosen.Jygfid.ToString()),
+                        new SugarParameter("@sxrq", (object?)chosen.NormalizedEffectiveDate ?? DBNull.Value),
+                        new SugarParameter("@now", now));
+
+                    await _db.Insertable(stRows).AS("qms_qcpp_inspbillst").ExecuteCommandAsync();
+                });
+                if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("生成检验单失败");
+
+                await TriggerRefreshSafeAsync();
+                return new { created = true, id = headId, billNo };
+            }
+            catch (Exception ex) when (IsDup(ex, "uk_qms_qcpp_inspbill_source"))
+            {
+                // 并发:另一请求已生成 → 回查已有,返回 created=false
+                var e2 = await QueryExistingInspAsync(tenantId, apply.BillNo!, apply.Id);
+                if (e2 != null) return new { created = false, id = e2.Id, billNo = e2.BillNo };
+                throw;
+            }
+            catch (Exception ex) when (IsDup(ex, "uk_qms_qcpp_inspbill_tenant_billno"))
+            {
+                // 号撞,重取重试
+                _ = ex;
+            }
+        }
+        throw Oops.Oh("检验单号并发冲突,请重试");
+    }
+
+    private async Task<ExistingInspRow?> QueryExistingInspAsync(long tenantId, string applyBillNo, long applyId)
+    {
+        return await _db.Ado.SqlQuerySingleAsync<ExistingInspRow>(
+            "SELECT id AS Id, FBILLNO AS BillNo FROM qms_qcpp_inspbill WHERE tenant_id=@t AND lydjbh=@ly AND hid=@hid LIMIT 1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@ly", applyBillNo), new SugarParameter("@hid", applyId));
+    }
+
+    /// <summary>选检规:人工指定优先(须属该物料候选);否则唯一 jygfid 直取;多版本取生效日期严格最新;同优先级歧义则拒。</summary>
+    private static FqcSpecCandidate ChooseSpec(List<FqcSpecCandidate> candidates, long? specId)
+    {
+        if (specId.HasValue && specId.Value > 0)
+            return candidates.FirstOrDefault(c => c.Jygfid == specId.Value) ?? throw Oops.Oh("所选检规不属于该物料的有效映射");
+        var distinct = candidates.Select(c => c.Jygfid).Distinct().ToList();
+        if (distinct.Count == 1) return candidates[0];
+        // candidates 已按 normalized_effective_date DESC 排序:取严格最新,否则歧义拒绝(禁 First 偷取)
+        var top = candidates[0];
+        var second = candidates.FirstOrDefault(c => c.Jygfid != top.Jygfid);
+        if (top.NormalizedEffectiveDate.HasValue &&
+            (second?.NormalizedEffectiveDate == null || top.NormalizedEffectiveDate.Value > second.NormalizedEffectiveDate.Value))
+            return top;
+        throw Oops.Oh("当前物料存在多个有效成品检规,请先完成检规映射确认(人工绑定唯一检规)");
+    }
+
+    private static int? ParseXh(string? xh) => int.TryParse((xh ?? "").Trim(), out var n) ? n : null;
+
+    /// <summary>检验单号 FQC+yyyyMMdd+4 位(配 1A uk_qms_qcpp_inspbill_tenant_billno + 上层重试)。</summary>
+    private async Task<string> AllocateInspBillNoAsync(long tenantId)
+    {
+        var prefix = $"FQC{DateTime.Now:yyyyMMdd}";
+        var max = await _db.Ado.SqlQuerySingleAsync<string>(
+            "SELECT FBILLNO FROM qms_qcpp_inspbill WHERE tenant_id=@t AND FBILLNO LIKE @p ORDER BY FBILLNO DESC LIMIT 1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@p", prefix + "%"));
+        if (string.IsNullOrWhiteSpace(max) || max.Length < 4) return $"{prefix}0001";
+        return int.TryParse(max[^4..], out var n) && n < 9999 ? $"{prefix}{(n + 1):0000}" : $"{prefix}0001";
+    }
+
+    private static bool IsDup(Exception ex, string indexName)
+    {
+        var m = ex.Message + " " + (ex.InnerException?.Message ?? "");
+        return (m.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) || m.Contains("1062")) && m.Contains(indexName, StringComparison.OrdinalIgnoreCase);
+    }
+
+    // ============================ 辅助 ============================
+
+    /// <summary>报检号 FQCBJ+yyyyMMdd+4 位(本库租户内,配 1A UNIQUE + 上层重试)。</summary>
+    private async Task<string> AllocateApplyNoAsync(long tenantId)
+    {
+        var prefix = $"FQCBJ{DateTime.Now:yyyyMMdd}";
+        var max = await _db.Ado.SqlQuerySingleAsync<string>(
+            "SELECT FBILLNO FROM qms_fqcbj WHERE tenant_id=@t AND FBILLNO LIKE @p ORDER BY FBILLNO DESC LIMIT 1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@p", prefix + "%"));
+        if (string.IsNullOrWhiteSpace(max) || max.Length < 4) return $"{prefix}0001";
+        return int.TryParse(max[^4..], out var n) && n < 9999 ? $"{prefix}{(n + 1):0000}" : $"{prefix}0001";
+    }
+
+    /// <summary>写成功后触发 FQC refresh(解耦,失败只记录不影响报检成立)。</summary>
+    private async Task TriggerRefreshSafeAsync()
+    {
+        try { await _fqcMdpSyncService.RunFullAsync(CancellationToken.None, "APPLY_CREATE"); }
+        catch (Exception ex) { _logger.LogWarning(ex, "[FqcApply] 报检已保存,但 FQC refresh 失败,可稍后手动刷新"); }
+    }
+
+    private static bool IsDupKey(Exception ex)
+    {
+        var m = ex.Message + " " + (ex.InnerException?.Message ?? "");
+        return m.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) || m.Contains("1062") || m.Contains("uk_qms_fqcbj");
+    }
+
+    private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
+}
+
+public class FqcWorkOrderQueryInput
+{
+    public string? Batch { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? LotSerial { get; set; }
+    public string? Status { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+public class FqcWorkOrderRow
+{
+    public long RecId { get; set; }
+    public string? Batch { get; set; }
+    public string? WorkOrd { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? ProductName { get; set; }
+    public string? ProductModel { get; set; }
+    public string? LotSerial { get; set; }
+    public decimal? QtyOrded { get; set; }
+    public decimal? QtyCompleted { get; set; }
+    public string? Status { get; set; }
+}
+
+public class FqcApplyQueryInput
+{
+    public string? BillNo { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? ProductionBatchNo { get; set; }
+    public string? ProductionOrderNo { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+}
+
+public class FqcApplyRow
+{
+    public long Id { get; set; }
+    public string? BillNo { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? ProductName { get; set; }
+    public string? ProductModel { get; set; }
+    public string? ProductionBatchNo { get; set; }
+    public string? ProductionOrderNo { get; set; }
+    public decimal? OrderQty { get; set; }
+    public decimal? Qty { get; set; }
+    public string? Priority { get; set; }
+    public string? FireNo { get; set; }
+    public string? Applicant { get; set; }
+    public DateTime? ApplyTime { get; set; }
+    public string? InspectStatus { get; set; }
+    public string? Remark { get; set; }
+    /// <summary>已生成的检验单 id/号(未生成为 null)</summary>
+    public long? InspBillId { get; set; }
+    public string? InspBillNo { get; set; }
+}
+
+public class FqcApplyCreateInput
+{
+    public long WorkOrdRecId { get; set; }
+    public decimal? Qty { get; set; }
+    public string? Priority { get; set; }
+    public string? FireNo { get; set; }
+    public string? Remark { get; set; }
+}
+
+internal sealed class WorkOrdGenRow
+{
+    public long RecId { get; set; }
+    public string? Batch { get; set; }
+    public string? ItemNum { get; set; }
+    public string? LotSerial { get; set; }
+    public decimal? QtyOrded { get; set; }
+    public string? ProductName { get; set; }
+    public string? ProductModel { get; set; }
+    public long? ForgId { get; set; }
+}
+
+public class FqcGenerateInput
+{
+    /// <summary>报检 id(qms_fqcbj.id)</summary>
+    public long ApplyId { get; set; }
+    /// <summary>可选:人工指定检规 id(须属该物料桥表候选,后端二次校验)</summary>
+    public long? SpecId { get; set; }
+}
+
+internal sealed class ApplyGenRow
+{
+    public long Id { get; set; }
+    public string? BillNo { get; set; }
+    public string? Wlbm { get; set; }
+    public string? Cpmc { get; set; }
+    public string? Cpxh { get; set; }
+    public string? Scph { get; set; }
+    public string? Sczld { get; set; }
+    public decimal? Sczldsl { get; set; }
+}
+
+internal sealed class ExistingInspRow
+{
+    public long Id { get; set; }
+    public string? BillNo { get; set; }
+}
+
+internal sealed class SpecDetailRow
+{
+    public long Id { get; set; }
+    public string? Xh { get; set; }
+    public string? Jyxm { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? LowerLimit { get; set; }
+    public string? Lrlx { get; set; }
+    public string? Jsyq { get; set; }
+    public string? Jyyqsbgj { get; set; }
+    public string? Jyff { get; set; }
+    public string? Jytk { get; set; }
+    public string? Jsbz { get; set; }
+    public string? Cyfa { get; set; }
+    public string? Jl { get; set; }
+    public string? SizeSpecifications { get; set; }
+}

+ 211 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspectionService.cs

@@ -0,0 +1,211 @@
+using System.Text;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 检验录入/自动判定(Phase 1E)。读写"活"检验单 qms_qcpp_inspbill / qms_qcpp_inspbillst。
+///
+/// NUMERIC 项目:逐样本录 j1..j50,自动判定按快照 sx/xx 判每个样本(xx≤v≤sx);
+/// NON_NUMERIC 项目:录本次实际合格数(NonNumericalTypeOK)/不合格数(NonNumericalTypeNG),无 Ac/Re 规则不自动判 → pd 人工确认。
+///
+/// 硬边界:① 自动判定只读 inspbillst 快照(sx/xx/lrlx),绝不回查实时检规 qms_ccjygfzb;
+///         ② NON_NUMERIC 无接收规则时不默认 NG>0=FAIL,pd 留人工;③ 保存不起流(仅本 service),
+///         首次正式提交走现有 FqcInspBillFlow.submit-result 惰性 StartFlow。tenant 全程 ResolveTenantOrThrow。
+/// 本 service 不做提交/审核/处置(提交复用现有流程端点)。
+/// </summary>
+[ApiDescriptionSettings(Order = 343, Description = "FQC检验录入")]
+[Route("api/S7FqcInspection")]
+[NonUnify]
+public class FqcInspectionService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public FqcInspectionService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
+    private const int SampleCols = 50; // j1..j50
+
+    /// <summary>检验单录入详情(活单 qms_qcpp_inspbill 头 + qms_qcpp_inspbillst 明细,含快照与已录入值)。</summary>
+    [DisplayName("检验单录入详情")]
+    [HttpGet("detail")]
+    public async Task<object?> Detail([FromQuery] long inspBillId)
+    {
+        if (inspBillId <= 0) return null;
+        var tenantId = ResolveTenantOrThrow();
+        var head = (await _db.Ado.SqlQueryAsync<FqcInspHeadRow>(
+            @"SELECT id AS Id, FBILLNO AS BillNo, lydjbh AS SourceBillNo, wlmc AS MaterialName, ggxh AS Spec,
+                     scph AS ProductionBatchNo, sczld AS ProductionOrderNo, jgbh AS SpecNo, jgbb AS SpecVersion,
+                     IFNULL(pd,'') AS Pd, hgsl AS QualifiedQty, bhgsl AS UnqualifiedQty, FBILLSTATUS AS BillStatus
+              FROM qms_qcpp_inspbill WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", inspBillId), new SugarParameter("@t", tenantId))).FirstOrDefault();
+        if (head == null) return null;
+
+        var jSel = string.Join(",", Enumerable.Range(1, SampleCols).Select(i => $"j{i}"));
+        var raw = await _db.Ado.SqlQueryAsync<dynamic>(
+            $@"SELECT id, xh, jyxm, IFNULL(lrlx,'') lrlx, IFNULL(sx,'') sx, IFNULL(xx,'') xx, jsyq, jsbz, cyfa, ybl,
+                     IFNULL(pd,'') pd, NonNumericalTypeOK, NonNumericalTypeNG, {jSel}
+              FROM qms_qcpp_inspbillst WHERE glid=@g AND tenant_id=@t ORDER BY xh, id",
+            new SugarParameter("@g", inspBillId), new SugarParameter("@t", tenantId));
+
+        var lines = new List<Dictionary<string, object?>>();
+        foreach (var r in raw)
+        {
+            var d = (IDictionary<string, object?>)r;
+            var samples = new List<string?>();
+            for (var i = 1; i <= SampleCols; i++) samples.Add(d.TryGetValue($"j{i}", out var v) ? v?.ToString() : null);
+            lines.Add(new Dictionary<string, object?>
+            {
+                ["id"] = d["id"], ["xh"] = d["xh"], ["jyxm"] = d["jyxm"], ["lrlx"] = d["lrlx"],
+                ["sx"] = d["sx"], ["xx"] = d["xx"], ["jsyq"] = d["jsyq"], ["jsbz"] = d["jsbz"], ["cyfa"] = d["cyfa"], ["ybl"] = d["ybl"],
+                ["pd"] = d["pd"], ["nonNumericalTypeOK"] = d["NonNumericalTypeOK"], ["nonNumericalTypeNG"] = d["NonNumericalTypeNG"],
+                ["samples"] = samples,
+            });
+        }
+        return new { head, lines };
+    }
+
+    /// <summary>保存录入(草稿,不起流):逐项写 j1..j50 / 非数值合格·不合格数 / 行 pd。</summary>
+    [DisplayName("保存检验录入")]
+    [HttpPost("save-detail")]
+    public async Task<object> SaveDetail([FromBody] FqcSaveDetailInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.InspBillId <= 0 || input.Lines == null || input.Lines.Count == 0)
+            throw Oops.Oh("入参非法");
+        // 归属校验
+        var owns = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM qms_qcpp_inspbill WHERE id=@id AND tenant_id=@t",
+            new SugarParameter("@id", input.InspBillId), new SugarParameter("@t", tenantId));
+        if (owns == 0) throw Oops.Oh("检验单不存在或不属于当前租户");
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            foreach (var ln in input.Lines)
+            {
+                var sets = new StringBuilder();
+                var pars = new List<SugarParameter>();
+                var samples = ln.Samples ?? new List<string?>();
+                for (var i = 1; i <= SampleCols; i++)
+                {
+                    var v = (i <= samples.Count) ? samples[i - 1] : null;
+                    sets.Append($"j{i}=@j{i},");
+                    pars.Add(new SugarParameter($"@j{i}", string.IsNullOrWhiteSpace(v) ? null : v.Trim()));
+                }
+                sets.Append("NonNumericalTypeOK=@ok, NonNumericalTypeNG=@ng, ybl=@ybl, pd=@pd");
+                pars.Add(new SugarParameter("@ok", (object?)ln.NonNumericalTypeOK ?? DBNull.Value));
+                pars.Add(new SugarParameter("@ng", (object?)ln.NonNumericalTypeNG ?? DBNull.Value));
+                pars.Add(new SugarParameter("@ybl", (object?)Nz(ln.Ybl) ?? DBNull.Value));
+                pars.Add(new SugarParameter("@pd", (object?)Nz(ln.Pd) ?? DBNull.Value));
+                pars.Add(new SugarParameter("@id", ln.Id));
+                pars.Add(new SugarParameter("@g", input.InspBillId));
+                pars.Add(new SugarParameter("@t", tenantId));
+                await _db.Ado.ExecuteCommandAsync(
+                    $"UPDATE qms_qcpp_inspbillst SET {sets} WHERE id=@id AND glid=@g AND tenant_id=@t", pars);
+            }
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("保存失败");
+        return new { ok = true, saved = input.Lines.Count };
+    }
+
+    /// <summary>
+    /// 自动判定:只读 inspbillst 快照(sx/xx/lrlx)。NUMERIC 且有上/下限 → 逐样本 xx≤v≤sx 判行 pd(合格/不合格);
+    /// NON_NUMERIC 或无上下限规则 → 跳过(pending),pd 留人工。局部失败不整单失败。
+    /// </summary>
+    [DisplayName("自动判定")]
+    [HttpPost("auto-judge")]
+    public async Task<object> AutoJudge([FromBody] FqcAutoJudgeInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.InspBillId <= 0) throw Oops.Oh("检验单 id 非法");
+
+        var jSel = string.Join(",", Enumerable.Range(1, SampleCols).Select(i => $"j{i}"));
+        var raw = await _db.Ado.SqlQueryAsync<dynamic>(
+            $"SELECT id, jyxm, IFNULL(lrlx,'') lrlx, IFNULL(sx,'') sx, IFNULL(xx,'') xx, {jSel} FROM qms_qcpp_inspbillst WHERE glid=@g AND tenant_id=@t",
+            new SugarParameter("@g", input.InspBillId), new SugarParameter("@t", tenantId));
+        if (!raw.Any()) throw Oops.Oh("检验单无明细");
+
+        int judged = 0, pending = 0;
+        var items = new List<object>();
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            foreach (var r in raw)
+            {
+                var d = (IDictionary<string, object?>)r;
+                var lrlx = d["lrlx"]?.ToString();
+                var sxStr = d["sx"]?.ToString(); var xxStr = d["xx"]?.ToString();
+                var hasUp = decimal.TryParse(sxStr, out var up);
+                var hasLo = decimal.TryParse(xxStr, out var lo);
+                // 硬边界②③:非数值 / 无数值规则 → 不自动判,pd 留人工
+                if (lrlx != "NUMERIC" || (!hasUp && !hasLo))
+                {
+                    pending++;
+                    items.Add(new { id = d["id"], jyxm = d["jyxm"]?.ToString(), result = "PENDING", reason = "无结构化数值判定条件,请人工确认" });
+                    continue;
+                }
+                var vals = new List<decimal>();
+                var anyEntered = false;
+                for (var i = 1; i <= SampleCols; i++)
+                {
+                    var s = d.TryGetValue($"j{i}", out var v) ? v?.ToString() : null;
+                    if (string.IsNullOrWhiteSpace(s)) continue;
+                    anyEntered = true;
+                    if (decimal.TryParse(s.Trim(), out var num)) vals.Add(num);
+                }
+                if (!anyEntered) { pending++; items.Add(new { id = d["id"], jyxm = d["jyxm"]?.ToString(), result = "PENDING", reason = "尚未录入样本值" }); continue; }
+                var pass = vals.All(v => (!hasLo || v >= lo) && (!hasUp || v <= up));
+                var pd = pass ? "合格" : "不合格";
+                await _db.Ado.ExecuteCommandAsync("UPDATE qms_qcpp_inspbillst SET pd=@pd WHERE id=@id AND glid=@g AND tenant_id=@t",
+                    new SugarParameter("@pd", pd), new SugarParameter("@id", d["id"]), new SugarParameter("@g", input.InspBillId), new SugarParameter("@t", tenantId));
+                judged++;
+                items.Add(new { id = d["id"], jyxm = d["jyxm"]?.ToString(), result = pd });
+            }
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("自动判定失败");
+        return new { judged, pending, items, message = pending > 0 ? $"已自动判定 {judged} 项,{pending} 项缺结构化判定条件请人工确认" : $"已自动判定 {judged} 项" };
+    }
+
+    private static string? Nz(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
+}
+
+public class FqcInspHeadRow
+{
+    public long Id { get; set; }
+    public string? BillNo { get; set; }
+    public string? SourceBillNo { get; set; }
+    public string? MaterialName { get; set; }
+    public string? Spec { get; set; }
+    public string? ProductionBatchNo { get; set; }
+    public string? ProductionOrderNo { get; set; }
+    public string? SpecNo { get; set; }
+    public string? SpecVersion { get; set; }
+    public string? Pd { get; set; }
+    public decimal? QualifiedQty { get; set; }
+    public decimal? UnqualifiedQty { get; set; }
+    public string? BillStatus { get; set; }
+}
+
+public class FqcSaveDetailInput
+{
+    public long InspBillId { get; set; }
+    public List<FqcSaveDetailLine> Lines { get; set; } = new();
+}
+
+public class FqcSaveDetailLine
+{
+    public long Id { get; set; }
+    public List<string?>? Samples { get; set; }
+    public long? NonNumericalTypeOK { get; set; }
+    public long? NonNumericalTypeNG { get; set; }
+    public string? Ybl { get; set; }
+    public string? Pd { get; set; }
+}
+
+public class FqcAutoJudgeInput
+{
+    public long InspBillId { get; set; }
+}

+ 56 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs

@@ -55,6 +55,11 @@ public class FqcMdpSyncService : ITransient
         await EnsureTablesAsync();
         await EnsurePullStgTableAsync();
 
+        // 1E-0:并发收口——同库同时只允许一个 FQC 全量刷新(多实例/多次触发串行化,抢不到则跳过,交下一轮)
+        var locked = await _db.Ado.GetIntAsync("SELECT GET_LOCK(@k, 15)", new List<SugarParameter> { new("@k", FqcRefreshLockKey) });
+        if (locked != 1)
+            return new FqcMdpSyncResult { BatchId = "SKIPPED_LOCK_BUSY", Skipped = true };
+
         var now = DateTime.Now;
         var batchId = $"S7_FQC_FULL_{now:yyyyMMddHHmmss}";
         var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
@@ -75,6 +80,10 @@ public class FqcMdpSyncService : ITransient
             result.TaskRows = await TransformTaskStandardAsync(batchId, now);
             result.ResultRows = await TransformResultStandardAsync(batchId, now);
             result.DetailRows = await TransformResultDetailStandardAsync(batchId, now);
+            // 1F:全量刷新后清理本库(AIDOP)贴源 orphan——源单据已删的 std 行(transform 只 upsert 不删,
+            // pull stg 跨全量批不清致旧会话行残留 → std 累积孤儿 → 结果列表虚增行)。仅清 AIDOP 源,
+            // SQLSERVER 双源(status=0 就位不启用)行按 source_system 排除,绝不误删。
+            await PurgeStdOrphansAsync();
             await MarkRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -83,6 +92,47 @@ public class FqcMdpSyncService : ITransient
             await MarkRunFailedAsync(runLogId, now, ex.Message);
             throw;
         }
+        finally
+        {
+            await _db.Ado.ExecuteCommandAsync("SELECT RELEASE_LOCK(@k)", new List<SugarParameter> { new("@k", FqcRefreshLockKey) });
+        }
+    }
+
+    /// <summary>FQC 全量刷新并发锁键(同库同时只跑一个)。</summary>
+    private const string FqcRefreshLockKey = "aidop:s7:fqc:refresh";
+
+    /// <summary>
+    /// 全量刷新后清理本库(AIDOP)贴源 orphan:源单据(qms_qcpp_inspbill / qms_fqcbj / qms_qcpp_inspbillst)
+    /// 已删除但 std 层仍残留的行。仅清 source_system='AIDOP'(含历史 NULL/空)的行,SQLSERVER 双源行不受影响;
+    /// detail 无 source_system 列,按父 result(glid=result.source_row_id)是否存在判定,与源系统无关且安全。
+    /// </summary>
+    private async Task PurgeStdOrphansAsync()
+    {
+        // 仅限本库 MySQL 源(source_system=AIDOPDEV_MYSQL, mdp_source.source_code);DEMO 合成源、
+        // DOPDEMORQ_SQLSERVER 双源均按 source_system 排除,绝不误删。
+        const string nativeScope = "s.source_system = 'AIDOPDEV_MYSQL'";
+        // 结果头:源 qms_qcpp_inspbill(source_row_id=inspbill.id)
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            DELETE s FROM {StdResultTable} s
+            LEFT JOIN qms_qcpp_inspbill q ON q.id = CAST(s.source_row_id AS UNSIGNED) AND q.tenant_id = s.tenant_id
+            WHERE {nativeScope} AND q.id IS NULL
+            """);
+        // 任务:源 qms_fqcbj(source_row_id=fqcbj.id)
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            DELETE s FROM {StdTaskTable} s
+            LEFT JOIN qms_fqcbj q ON q.id = CAST(s.source_row_id AS UNSIGNED) AND q.tenant_id = s.tenant_id
+            WHERE {nativeScope} AND q.id IS NULL
+            """);
+        // 明细:本库源 qms_qcpp_inspbillst(source_row_id=inspbillst.id, 数值型);非本库(DEMO 的 source_row_id
+        // 形如 DEMO-FQC-SRC-*)由数值正则排除,绝不误删。父结果被上面清理后本库孤儿明细亦随源缺失被清。
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            DELETE d FROM {StdResultDetailTable} d
+            LEFT JOIN qms_qcpp_inspbillst q ON q.id = CAST(d.source_row_id AS UNSIGNED) AND q.tenant_id = d.tenant_id
+            WHERE d.source_row_id REGEXP '^[0-9]+$' AND q.id IS NULL
+            """);
     }
 
     /// <summary>双模式入站:执行器抽三表 → pull stg,再跑标准层三表(读 stg)。</summary>
@@ -206,9 +256,10 @@ public class FqcMdpSyncService : ITransient
     private async Task<(int RowsPulled, int RowsWritten, string? NewCursor)> PopulateStgAsync(
         MdpPullContext pullCtx, CancellationToken cancellationToken)
     {
-        var task = await _pullDispatcher.PullByEntityCodeAsync(InboundTaskEntityCode, pullCtx, cancellationToken);
-        var res = await _pullDispatcher.PullByEntityCodeAsync(InboundResultEntityCode, pullCtx, cancellationToken);
-        var det = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+        // 1E-0:改分页抽尽,消除单页 5000 行截断(源表 > batch_size 时单页只灌前 5000 → std 缺尾)
+        var task = await _pullDispatcher.PullAllByEntityCodeAsync(InboundTaskEntityCode, pullCtx, cancellationToken);
+        var res = await _pullDispatcher.PullAllByEntityCodeAsync(InboundResultEntityCode, pullCtx, cancellationToken);
+        var det = await _pullDispatcher.PullAllByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
         return (
             task.RowsPulled + res.RowsPulled + det.RowsPulled,
             task.RowsWritten + res.RowsWritten + det.RowsWritten,
@@ -608,6 +659,8 @@ public sealed class FqcMdpSyncResult
     public int TaskRows { get; set; }
     public int ResultRows { get; set; }
     public int DetailRows { get; set; }
+    /// <summary>1E-0:并发锁未抢到、本次跳过(交由持锁者或下一轮刷新)。</summary>
+    public bool Skipped { get; set; }
 }
 
 /// <summary>S7 FQC 双模式入站结果。</summary>

+ 372 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcSpecMaterialMapService.cs

@@ -0,0 +1,372 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 检规↔标准物料 派生映射服务(G1)。
+///
+/// 目的:解决"报检物料 → 检规"精确匹配。源 qms_ccjygf.wlbm 是多物料脏串(分隔符不统一 + 粘连 + 横杠区间),
+/// 直接 LIKE 会误配(实证 31 token 互为子串)。本服务把 wlbm 清洗成 token,与 ItemMaster.ItemNum 精确匹配,
+/// 落派生桥表 ado_s7_fqc_spec_material_map,供"报检物料→检规"稳定查询与人工订正。
+///
+/// 硬边界:① 绝不修改原始 qms_ccjygf.wlbm/sxrq;② 禁止 LIKE 子串匹配;③ 粘连不自动拆(词典缺片段),标 GLUE_UNRESOLVED;
+/// ④ 全部按 ResolveTenantOrThrow 登录租户隔离,禁透传源行租户。
+/// </summary>
+[ApiDescriptionSettings(Order = 340, Description = "FQC检规物料映射")]
+[Route("api/S7FqcSpecMap")]
+[NonUnify]
+public class FqcSpecMaterialMapService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public FqcSpecMaterialMapService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    /// <summary>wlbm 拆分正则:半/全角分号逗号、换行回车 tab、斜杠、半/全角冒号、空白。</summary>
+    private static readonly Regex TokenSplitter = new(@"[;;,,\r\n\t/::\s]+", RegexOptions.Compiled);
+
+    /// <summary>纯物料码形态(字母数字,长度 4-12);用于粘连/异常识别(非白名单,仅分级)。</summary>
+    private static readonly Regex PlainCodeShape = new(@"^[A-Za-z0-9]{4,12}$", RegexOptions.Compiled);
+
+    // ============================ 清洗重建 ============================
+
+    /// <summary>
+    /// 重建当前租户的检规↔物料映射(清洗 wlbm → 精确匹配 ItemMaster → 落桥表)。
+    /// 只删除并重建自动派生行(is_manual=0);人工绑定行(is_manual=1)原样保留。
+    /// </summary>
+    [DisplayName("重建检规物料映射")]
+    [HttpPost("rebuild")]
+    public async Task<object> Rebuild(CancellationToken ct)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var now = DateTime.Now;
+        var userId = _userManager.UserId;
+
+        // 1) 读检规主表
+        var specs = await _db.Ado.SqlQueryAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, sxrq AS Sxrq, wlbm AS Wlbm FROM qms_ccjygf WHERE tenant_id=@t",
+            new SugarParameter("@t", tenantId));
+
+        // 2) 物料词典(当前租户 ItemMaster.ItemNum 精确集合)
+        var itemSet = new HashSet<string>(
+            await _db.Ado.SqlQueryAsync<string>(
+                "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
+                new SugarParameter("@t", tenantId)),
+            StringComparer.OrdinalIgnoreCase);
+
+        var rows = new List<Dictionary<string, object>>();
+        int exact = 0, notFound = 0, glue = 0, total = 0;
+
+        foreach (var s in specs)
+        {
+            var norm = NormalizeDate(s.Sxrq);
+            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            var tokens = SplitTokens(s.Wlbm);
+            int seq = 0;
+            foreach (var raw in tokens)
+            {
+                var cleaned = raw.Trim();
+                if (cleaned.Length == 0) continue;
+                if (!seen.Add(cleaned)) continue; // 同检规内去重
+                seq++;
+                total++;
+
+                string status;
+                string? matched = null;
+                if (itemSet.Contains(cleaned)) { status = "EXACT"; matched = cleaned; exact++; }
+                else if (!PlainCodeShape.IsMatch(cleaned)) { status = "GLUE_UNRESOLVED"; glue++; } // 含区间/异常字符/超长=粘连或非单码
+                else { status = "NOT_FOUND"; notFound++; }
+
+                rows.Add(new Dictionary<string, object>
+                {
+                    ["id"] = YitIdHelper.NextId(),
+                    ["tenant_id"] = tenantId,
+                    ["jygfid"] = s.Id,
+                    ["wjbh"] = s.Wjbh,
+                    ["bb"] = s.Bb,
+                    ["seq"] = seq,
+                    ["raw_token"] = raw,
+                    ["cleaned_token"] = cleaned,
+                    ["material_code"] = (object?)matched ?? DBNull.Value,
+                    ["match_status"] = status,
+                    ["normalized_effective_date"] = (object?)norm ?? DBNull.Value,
+                    ["note"] = DBNull.Value,
+                    ["is_manual"] = 0,
+                    ["status"] = 1,
+                    ["create_time"] = now,
+                    ["create_user_id"] = (object?)userId ?? DBNull.Value,
+                });
+            }
+        }
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            // 只清自动派生行,人工绑定(is_manual=1)保留
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM ado_s7_fqc_spec_material_map WHERE tenant_id=@t AND is_manual=0",
+                new SugarParameter("@t", tenantId));
+            if (rows.Count > 0)
+                await BulkInsertMapRowsAsync(rows);
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("重建映射失败");
+
+        return new
+        {
+            ok = true,
+            specCount = specs.Count,
+            token = total,
+            exact,
+            notFound,
+            glueUnresolved = glue,
+            exactRate = total == 0 ? 0 : Math.Round(exact * 100.0 / total, 1)
+        };
+    }
+
+    // ============================ 查询/维护 ============================
+
+    /// <summary>映射列表(维护用,可按物料/状态筛选)。</summary>
+    [DisplayName("检规物料映射列表")]
+    [HttpGet("page")]
+    public async Task<object> Page([FromQuery] FqcSpecMapPageInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
+        var where = new List<string> { "m.tenant_id=@t" };
+        var pars = new List<SugarParameter> { new("@t", tenantId) };
+        if (!string.IsNullOrWhiteSpace(input.MaterialCode))
+        {
+            where.Add("m.cleaned_token=@mc"); // 精确,禁 LIKE
+            pars.Add(new SugarParameter("@mc", input.MaterialCode.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.MatchStatus))
+        {
+            where.Add("m.match_status=@ms");
+            pars.Add(new SugarParameter("@ms", input.MatchStatus.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Wjbh))
+        {
+            where.Add("m.wjbh=@wj");
+            pars.Add(new SugarParameter("@wj", input.Wjbh.Trim()));
+        }
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ado_s7_fqc_spec_material_map m WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<FqcSpecMapRow>(
+            $@"SELECT m.id AS Id, m.jygfid AS Jygfid, m.wjbh AS Wjbh, m.bb AS Bb, m.raw_token AS RawToken,
+                     m.cleaned_token AS CleanedToken, m.material_code AS MaterialCode, m.match_status AS MatchStatus,
+                     m.normalized_effective_date AS NormalizedEffectiveDate, m.is_manual AS IsManual, m.note AS Note
+              FROM ado_s7_fqc_spec_material_map m WHERE {whereSql}
+              ORDER BY m.match_status='EXACT' DESC, m.wjbh, m.seq
+              LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>人工绑定:把一个标准物料码绑定到指定检规(is_manual=1,脱敏环境订正用;不改源 wlbm)。</summary>
+    [DisplayName("人工绑定检规物料")]
+    [HttpPost("bind")]
+    public async Task<object> Bind([FromBody] FqcSpecMapBindInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.Jygfid <= 0 || string.IsNullOrWhiteSpace(input.MaterialCode))
+            throw Oops.Oh("检规 id 与物料编码必填");
+        var materialCode = input.MaterialCode.Trim();
+
+        var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, sxrq AS Sxrq FROM qms_ccjygf WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", input.Jygfid), new SugarParameter("@t", tenantId));
+        if (spec == null) throw Oops.Oh("检规不存在或不属于当前租户");
+
+        var itemExists = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM ItemMaster WHERE tenant_id=@t AND ItemNum=@mc",
+            new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
+        if (itemExists == 0) throw Oops.Oh($"物料 {materialCode} 不在当前租户 ItemMaster,无法绑定");
+
+        // 幂等:同租户+检规+物料+人工 唯一(raw_token 用 materialCode 承载)
+        var dup = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM ado_s7_fqc_spec_material_map WHERE tenant_id=@t AND jygfid=@j AND cleaned_token=@mc AND is_manual=1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@j", input.Jygfid), new SugarParameter("@mc", materialCode));
+        if (dup > 0) return new { ok = true, message = "已存在该人工绑定" };
+
+        await BulkInsertMapRowsAsync(new List<Dictionary<string, object>>
+        {
+            new()
+            {
+                ["id"] = YitIdHelper.NextId(),
+                ["tenant_id"] = tenantId,
+                ["jygfid"] = input.Jygfid,
+                ["wjbh"] = spec.Wjbh,
+                ["bb"] = spec.Bb,
+                ["seq"] = 0,
+                ["raw_token"] = materialCode,
+                ["cleaned_token"] = materialCode,
+                ["material_code"] = materialCode,
+                ["match_status"] = "MANUAL",
+                ["normalized_effective_date"] = (object?)NormalizeDate(spec.Sxrq) ?? DBNull.Value,
+                ["note"] = input.Note,
+                ["is_manual"] = 1,
+                ["status"] = 1,
+                ["create_time"] = DateTime.Now,
+                ["create_user_id"] = _userManager.UserId,
+            }
+        });
+        return new { ok = true };
+    }
+
+    /// <summary>解绑人工绑定。</summary>
+    [DisplayName("解绑检规物料")]
+    [HttpPost("unbind")]
+    public async Task<object> Unbind([FromBody] FqcSpecMapUnbindInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.Id <= 0) throw Oops.Oh("id 必填");
+        var n = await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM ado_s7_fqc_spec_material_map WHERE id=@id AND tenant_id=@t AND is_manual=1",
+            new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
+        return new { ok = n > 0 };
+    }
+
+    /// <summary>
+    /// 按物料精确解析检规候选(供生成检验单调用)。返回 EXACT/MANUAL 命中的检规,多命中/多版本由调用方转人工选。
+    /// </summary>
+    [DisplayName("按物料解析检规")]
+    [HttpGet("resolve")]
+    public async Task<List<FqcSpecCandidate>> Resolve([FromQuery] string materialCode)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (string.IsNullOrWhiteSpace(materialCode)) throw Oops.Oh("物料编码必填");
+        return await ResolveInternalAsync(tenantId, materialCode.Trim());
+    }
+
+    /// <summary>内部复用:按物料精确取检规候选(EXACT/MANUAL),按生效日期新→旧排序。</summary>
+    public async Task<List<FqcSpecCandidate>> ResolveInternalAsync(long tenantId, string materialCode)
+    {
+        return await _db.Ado.SqlQueryAsync<FqcSpecCandidate>(
+            @"SELECT g.id AS Jygfid, g.wjbh AS Wjbh, g.bb AS Bb, g.sxrq AS Sxrq,
+                     m.normalized_effective_date AS NormalizedEffectiveDate, m.match_status AS MatchStatus
+              FROM ado_s7_fqc_spec_material_map m
+              JOIN qms_ccjygf g ON g.id=m.jygfid AND g.tenant_id=m.tenant_id
+              WHERE m.tenant_id=@t AND m.status=1 AND m.material_code=@mc
+                AND m.match_status IN ('EXACT','MANUAL')
+              ORDER BY m.normalized_effective_date DESC, g.id DESC",
+            new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
+    }
+
+    // ============================ 辅助 ============================
+
+    private static IEnumerable<string> SplitTokens(string? wlbm)
+    {
+        if (string.IsNullOrWhiteSpace(wlbm)) return Array.Empty<string>();
+        return TokenSplitter.Split(wlbm).Where(x => !string.IsNullOrWhiteSpace(x));
+    }
+
+    /// <summary>
+    /// 参数化分块批插桥表行。替代 db.Insertable(List&lt;Dictionary&gt;).AS(表)——后者对 nullable datetime 列
+    /// (normalized_effective_date) 的 DBNull 会被 SqlSugar 字典批插按空串渲染,触发 MySQL
+    /// "Incorrect datetime value: ''"。改用显式参数化多行 INSERT(SugarParameter 正确处理 DBNull→NULL)。
+    /// </summary>
+    private static readonly string[] MapColumns =
+    {
+        "id", "tenant_id", "jygfid", "wjbh", "bb", "seq", "raw_token", "cleaned_token", "material_code",
+        "match_status", "normalized_effective_date", "note", "is_manual", "status", "create_time", "create_user_id"
+    };
+
+    private async Task BulkInsertMapRowsAsync(List<Dictionary<string, object>> rows)
+    {
+        if (rows == null || rows.Count == 0) return;
+        const int chunkSize = 200;
+        var colList = string.Join(", ", MapColumns);
+        for (var start = 0; start < rows.Count; start += chunkSize)
+        {
+            var slice = rows.GetRange(start, Math.Min(chunkSize, rows.Count - start));
+            var valueClauses = new List<string>(slice.Count);
+            var pars = new List<SugarParameter>(slice.Count * MapColumns.Length);
+            for (var r = 0; r < slice.Count; r++)
+            {
+                var placeholders = new string[MapColumns.Length];
+                for (var c = 0; c < MapColumns.Length; c++)
+                {
+                    var name = $"@{MapColumns[c]}_{r}";
+                    placeholders[c] = name;
+                    var val = slice[r].TryGetValue(MapColumns[c], out var v) ? (v ?? DBNull.Value) : DBNull.Value;
+                    pars.Add(new SugarParameter(name, val));
+                }
+                valueClauses.Add("(" + string.Join(", ", placeholders) + ")");
+            }
+            var sql = $"INSERT INTO ado_s7_fqc_spec_material_map ({colList}) VALUES {string.Join(", ", valueClauses)}";
+            await _db.Ado.ExecuteCommandAsync(sql, pars);
+        }
+    }
+
+    /// <summary>规范化 sxrq(容错 dot/双点/横杠/带时分秒/非日期),失败返回 null(不覆盖源)。</summary>
+    private static DateTime? NormalizeDate(string? sxrq)
+    {
+        if (string.IsNullOrWhiteSpace(sxrq)) return null;
+        var s = sxrq.Trim().Replace("..", ".").Replace('.', '-').Replace('/', '-');
+        var datePart = s.Split(' ')[0];
+        if (DateTime.TryParse(datePart, out var d)) return d.Date;
+        return null;
+    }
+
+    private sealed class SpecRow
+    {
+        public long Id { get; set; }
+        public string? Wjbh { get; set; }
+        public string? Bb { get; set; }
+        public string? Sxrq { get; set; }
+        public string? Wlbm { get; set; }
+    }
+}
+
+public class FqcSpecMapPageInput
+{
+    public string? MaterialCode { get; set; }
+    public string? MatchStatus { get; set; }
+    public string? Wjbh { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+public class FqcSpecMapRow
+{
+    public long Id { get; set; }
+    public long Jygfid { get; set; }
+    public string? Wjbh { get; set; }
+    public string? Bb { get; set; }
+    public string? RawToken { get; set; }
+    public string? CleanedToken { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? MatchStatus { get; set; }
+    public DateTime? NormalizedEffectiveDate { get; set; }
+    public int IsManual { get; set; }
+    public string? Note { get; set; }
+}
+
+public class FqcSpecMapBindInput
+{
+    public long Jygfid { get; set; }
+    public string MaterialCode { get; set; } = string.Empty;
+    public string? Note { get; set; }
+}
+
+public class FqcSpecMapUnbindInput
+{
+    public long Id { get; set; }
+}
+
+public class FqcSpecCandidate
+{
+    public long Jygfid { get; set; }
+    public string? Wjbh { get; set; }
+    public string? Bb { get; set; }
+    public string? Sxrq { get; set; }
+    public DateTime? NormalizedEffectiveDate { get; set; }
+    public string? MatchStatus { get; set; }
+}

+ 217 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcSpecRuleService.cs

@@ -0,0 +1,217 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 检规结构化判定维护(Phase 1B,语义纠偏版)。
+///
+/// 检规模板 qms_ccjygfzb 维护每个检验项目的"录入类型 + 数值判定条件":
+///   lrlx=NUMERIC 数值型:维护 UpperToleranceLimit(上限)/LowerToleranceLimit(下限)
+///                        → 生成检验单时快照进 inspbillst.sx/xx,检验员按样本录 j1..j50,未来 xx≤jN≤sx 自动判定;
+///   lrlx=NON_NUMERIC 非数值型:不维护上下限,检验时只录"本次实际合格数/不合格数"
+///                        → 落 inspbillst.NonNumericalTypeOK/NG(检验结果数量,非检规模板字段,本服务绝不写)。
+/// jsyq(技术要求) 为人读原文,不解析、不覆盖。
+///
+/// 【语义纠偏】NonNumericalTypeOK/NG = 检验单本次实际合格/不合格数量,属 inspbillst,绝不放检规模板;
+///   本服务不再维护任何"非数值规则编码"。检规模板的非数值项目仅由 lrlx 标识类型。
+/// 硬边界:不新建规则表、不从 jsyq 猜类型、不批量填限值、不改历史数据;tenant 走 ResolveTenantOrThrow;
+///         上下限长度 ≤255(保证快照进 inspbillst.sx/xx varchar(255) 无截断)。
+/// </summary>
+[ApiDescriptionSettings(Order = 341, Description = "FQC检规判定维护")]
+[Route("api/S7FqcSpecRule")]
+[NonUnify]
+public class FqcSpecRuleService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public FqcSpecRuleService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    /// <summary>快照承载长度上界(inspbillst.sx/xx varchar(255))。</summary>
+    private const int LimitMaxLen = 255;
+    private const string TypeNumeric = "NUMERIC";
+    private const string TypeNonNumeric = "NON_NUMERIC";
+
+    /// <summary>检规主表列表(维护入口第一层:选检规)。</summary>
+    [DisplayName("检规主表列表")]
+    [HttpGet("spec-page")]
+    public async Task<object> SpecPage([FromQuery] FqcSpecRulePageInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
+        var where = new List<string> { "tenant_id=@t" };
+        var pars = new List<SugarParameter> { new("@t", tenantId) };
+        if (!string.IsNullOrWhiteSpace(input.Wjbh)) { where.Add("wjbh LIKE @w"); pars.Add(new("@w", $"%{input.Wjbh.Trim()}%")); }
+        if (!string.IsNullOrWhiteSpace(input.Cpmc)) { where.Add("cpmc LIKE @c"); pars.Add(new("@c", $"%{input.Cpmc.Trim()}%")); }
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_ccjygf WHERE {whereSql}", pars);
+        var list = await _db.Ado.SqlQueryAsync<FqcSpecHeadRow>(
+            $@"SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, cpmc AS Cpmc, cpxh AS Cpxh, sxrq AS Sxrq
+               FROM qms_ccjygf WHERE {whereSql} ORDER BY wjbh, bb DESC LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>某检规的检验项目明细 + 录入类型 + 数值上下限(维护第二层)。</summary>
+    [DisplayName("检规明细判定条件")]
+    [HttpGet("detail-list")]
+    public async Task<object> DetailList([FromQuery] long jygfid)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (jygfid <= 0) throw Oops.Oh("检规 id 非法");
+        var rows = await _db.Ado.SqlQueryAsync<FqcSpecRuleRow>(
+            @"SELECT z.id AS Id, z.xh AS Xh, z.jyxm AS Jyxm, z.jsyq AS Jsyq, z.jsbz AS Jsbz, z.cyfa AS Cyfa,
+                     z.lrlx AS Lrlx, z.UpperToleranceLimit AS UpperLimit, z.LowerToleranceLimit AS LowerLimit
+              FROM qms_ccjygfzb z
+              JOIN qms_ccjygf g ON g.id = z.glid AND g.tenant_id = z.tenant_id
+              WHERE z.glid = @j AND z.tenant_id = @t
+              ORDER BY z.xh, z.id",
+            new SugarParameter("@j", jygfid), new SugarParameter("@t", tenantId));
+        foreach (var r in rows) r.JudgmentType = DeriveJudgmentType(r.Lrlx, r.UpperLimit, r.LowerLimit);
+        return new { jygfid, list = rows };
+    }
+
+    /// <summary>保存单条检验项目的录入类型 + 数值上下限(后端全量兜底校验)。</summary>
+    [DisplayName("保存检规判定条件")]
+    [HttpPost("save-rule")]
+    public async Task<object> SaveRule([FromBody] FqcSpecRuleSaveInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.Id <= 0) throw Oops.Oh("明细 id 非法");
+
+        var lrlx = NormalizeType(input.Lrlx);
+        var upper = Trim(input.UpperLimit);
+        var lower = Trim(input.LowerLimit);
+
+        // 1) 明细存在且属当前租户
+        var exist = await _db.Ado.SqlQuerySingleAsync<long>(
+            "SELECT COUNT(1) FROM qms_ccjygfzb WHERE id=@id AND tenant_id=@t",
+            new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
+        if (exist == 0) throw Oops.Oh("检规明细不存在或不属于当前租户");
+
+        // 2) 非数值型不维护上下限:切换到 NON_NUMERIC 时清空上下限(避免残留脏数据)
+        if (lrlx == TypeNonNumeric)
+        {
+            upper = string.Empty;
+            lower = string.Empty;
+        }
+
+        // 3) 数值上下限校验(仅当有值时);NUMERIC 但暂无限值(未维护)允许
+        var hasLimit = !string.IsNullOrEmpty(upper) || !string.IsNullOrEmpty(lower);
+        if (hasLimit)
+        {
+            if (lrlx == TypeNonNumeric) throw Oops.Oh("非数值型项目不能配置上下限");
+            if (upper.Length > LimitMaxLen || lower.Length > LimitMaxLen)
+                throw Oops.Oh($"上/下限长度不能超过 {LimitMaxLen}(快照承载限制)");
+            decimal? u = ParseDec(upper), l = ParseDec(lower);
+            if (!string.IsNullOrEmpty(upper) && u == null) throw Oops.Oh("上限必须是数值");
+            if (!string.IsNullOrEmpty(lower) && l == null) throw Oops.Oh("下限必须是数值");
+            if (u.HasValue && l.HasValue && l.Value > u.Value) throw Oops.Oh("下限不能大于上限");
+        }
+
+        // 4) 写回
+        var n = await _db.Ado.ExecuteCommandAsync(
+            @"UPDATE qms_ccjygfzb
+              SET lrlx=@lrlx, UpperToleranceLimit=@upper, LowerToleranceLimit=@lower
+              WHERE id=@id AND tenant_id=@t",
+            new SugarParameter("@lrlx", (object?)lrlx ?? DBNull.Value),
+            new SugarParameter("@upper", (object?)NullIfEmpty(upper) ?? DBNull.Value),
+            new SugarParameter("@lower", (object?)NullIfEmpty(lower) ?? DBNull.Value),
+            new SugarParameter("@id", input.Id),
+            new SugarParameter("@t", tenantId));
+
+        return new { ok = n > 0, judgmentType = DeriveJudgmentType(lrlx, upper, lower) };
+    }
+
+    /// <summary>清空某明细的录入类型与上下限(→ 未配置)。</summary>
+    [DisplayName("清空检规判定条件")]
+    [HttpPost("clear-rule")]
+    public async Task<object> ClearRule([FromBody] FqcSpecRuleClearInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.Id <= 0) throw Oops.Oh("明细 id 非法");
+        var n = await _db.Ado.ExecuteCommandAsync(
+            @"UPDATE qms_ccjygfzb SET lrlx=NULL, UpperToleranceLimit=NULL, LowerToleranceLimit=NULL
+              WHERE id=@id AND tenant_id=@t",
+            new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
+        return new { ok = n > 0, judgmentType = "NONE" };
+    }
+
+    // ===== 辅助 =====
+
+    /// <summary>判定方式推导:显式 lrlx 优先;未设置时有上下限视为 NUMERIC,否则 NONE。
+    /// (不从"上下限为空"反推 NON_NUMERIC,非数值型必须显式 lrlx=NON_NUMERIC 标注。)</summary>
+    private static string DeriveJudgmentType(string? lrlx, string? upper, string? lower)
+    {
+        if (lrlx == TypeNumeric) return TypeNumeric;
+        if (lrlx == TypeNonNumeric) return TypeNonNumeric;
+        var hasLimit = !string.IsNullOrWhiteSpace(upper) || !string.IsNullOrWhiteSpace(lower);
+        return hasLimit ? TypeNumeric : "NONE";
+    }
+
+    private static string? NormalizeType(string? t)
+    {
+        if (string.IsNullOrWhiteSpace(t)) return null;
+        var v = t.Trim().ToUpperInvariant();
+        if (v == TypeNumeric || v == TypeNonNumeric) return v;
+        throw Oops.Oh("录入类型仅支持 NUMERIC / NON_NUMERIC");
+    }
+
+    private static string Trim(string? s) => (s ?? string.Empty).Trim();
+    private static string? NullIfEmpty(string s) => string.IsNullOrEmpty(s) ? null : s;
+    private static decimal? ParseDec(string s) => decimal.TryParse(s, out var d) ? d : null;
+}
+
+public class FqcSpecRulePageInput
+{
+    public string? Wjbh { get; set; }
+    public string? Cpmc { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+}
+
+public class FqcSpecHeadRow
+{
+    public long Id { get; set; }
+    public string? Wjbh { get; set; }
+    public string? Bb { get; set; }
+    public string? Cpmc { get; set; }
+    public string? Cpxh { get; set; }
+    public string? Sxrq { get; set; }
+}
+
+public class FqcSpecRuleRow
+{
+    public long Id { get; set; }
+    public string? Xh { get; set; }
+    public string? Jyxm { get; set; }
+    public string? Jsyq { get; set; }
+    public string? Jsbz { get; set; }
+    public string? Cyfa { get; set; }
+    /// <summary>录入类型 NUMERIC / NON_NUMERIC / null</summary>
+    public string? Lrlx { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? LowerLimit { get; set; }
+    /// <summary>推导判定方式 NUMERIC / NON_NUMERIC / NONE</summary>
+    public string? JudgmentType { get; set; }
+}
+
+public class FqcSpecRuleSaveInput
+{
+    public long Id { get; set; }
+    /// <summary>录入类型 NUMERIC / NON_NUMERIC(可空=未设置)</summary>
+    public string? Lrlx { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? LowerLimit { get; set; }
+}
+
+public class FqcSpecRuleClearInput
+{
+    public long Id { get; set; }
+}

+ 34 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs

@@ -39,6 +39,7 @@ public static class AidopMenuLinkSync
         EnsureLinkagePlanMenu(db);
         RemoveDeprecatedS8DashboardChildMenu(db);
         NormalizeS8MenuParents(db);
+        EnsureS7FqcSpecRuleSeedMenu(db);
         var seedMenus = new global::Admin.NET.Plugin.AiDOP.SysMenuSeedData().HasData().ToList();
         var seedMenuIds = seedMenus.Select(m => m.Id).ToHashSet();
 
@@ -210,6 +211,39 @@ public static class AidopMenuLinkSync
         }
     }
 
+    /// <summary>
+    /// S7 成品检规判定维护菜单(Phase 1B):种子关闭的共享库不会自动入库新增叶子菜单,
+    /// 从 <see cref="SysMenuSeedData.HasData"/> 按父链补插「成品检规判定维护」菜单及其 2 个按钮权限点
+    /// (成品质量管理目录已存在,仅补 3 个新行)。补插后由主授权路径授予租户/角色。
+    /// </summary>
+    private static void EnsureS7FqcSpecRuleSeedMenu(ISqlSugarClient db)
+    {
+        var fullSeed = new global::Admin.NET.Plugin.AiDOP.SysMenuSeedData().HasData().ToList();
+        var byId = fullSeed.GroupBy(m => m.Id).ToDictionary(g => g.Key, g => g.First());
+        var orderedIds = new long[]
+        {
+            1322000000023L,   // 成品质量管理(Dir,已存在)
+            1329017010003L,   // 成品检规判定维护(Menu)
+            1329017010004L,   // 保存判定条件(Btn 权限点 S7FqcSpecRule:save-rule)
+            1329017010005L,   // 清空判定条件(Btn 权限点 S7FqcSpecRule:clear-rule)
+            1329017010006L,   // 成品报检(Menu,Phase 1C)
+            1329017010007L,   // 检验单录入(隐藏路由,Phase 1E)
+        };
+        var existing = db.Queryable<SysMenu>().Where(m => orderedIds.Contains(m.Id)).Select(m => m.Id).ToList().ToHashSet();
+        var cumulativeIds = new HashSet<long>(existing);
+        var toInsert = new List<SysMenu>();
+        foreach (var id in orderedIds)
+        {
+            if (cumulativeIds.Contains(id)) continue;
+            if (!byId.TryGetValue(id, out var row)) continue;
+            if (row.Pid != 0 && !cumulativeIds.Contains(row.Pid)) continue;
+            toInsert.Add(row);
+            cumulativeIds.Add(id);
+        }
+        if (toInsert.Count > 0)
+            db.Insertable(toInsert).ExecuteCommand();
+    }
+
     /// <summary>
     /// 数据中台管理:数据库关闭种子时,补插 Ai-DOP 下的数据中台统一入口和演示子页。
     /// </summary>

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

@@ -617,6 +617,14 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         const long fqcBase = 1329017010000L;
         yield return new SysMenu { Id = fqcBase + 1, Pid = fqcDirId, Title = "FQC检验任务列表", Path = "/aidop/s7/fqc/task-list", Name = "aidopS7FqcTaskList", Component = "/aidop/s7/fqc/fqcInspectionTaskList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 10, Remark = "S7 FQC检验任务列表(只读列表)" };
         yield return new SysMenu { Id = fqcBase + 2, Pid = fqcDirId, Title = "FQC检验结果列表", Path = "/aidop/s7/fqc/result-list", Name = "aidopS7FqcResultList", Component = "/aidop/s7/fqc/fqcInspectionResultList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 20, Remark = "S7 FQC检验结果列表(只读列表)" };
+        // 成品检规判定维护(Phase 1B):查看不设权限;写端点由下方 Btn 权限点门禁(JwtHandler 全局校验 routeName=S7FqcSpecRule:save-rule/clear-rule)
+        yield return new SysMenu { Id = fqcBase + 3, Pid = fqcDirId, Title = "成品检规判定维护", Path = "/aidop/s7/fqc/spec-rule", Name = "aidopS7FqcSpecRule", Component = "/aidop/s7/fqc/fqcSpecRuleList", Icon = "ele-SetUp", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 30, Remark = "S7 成品检规结构化判定维护" };
+        yield return new SysMenu { Id = fqcBase + 4, Pid = fqcBase + 3, Title = "保存判定条件", Permission = "S7FqcSpecRule:save-rule", Name = "aidopS7FqcSpecRuleSave", Type = MenuTypeEnum.Btn, CreateTime = ct, OrderNo = 10, Remark = "S7 检规判定维护-保存权限点" };
+        yield return new SysMenu { Id = fqcBase + 5, Pid = fqcBase + 3, Title = "清空判定条件", Permission = "S7FqcSpecRule:clear-rule", Name = "aidopS7FqcSpecRuleClear", Type = MenuTypeEnum.Btn, CreateTime = ct, OrderNo = 20, Remark = "S7 检规判定维护-清空权限点" };
+        // 成品报检(Phase 1C):引用完工工单创建报检
+        yield return new SysMenu { Id = fqcBase + 6, Pid = fqcDirId, Title = "成品报检", Path = "/aidop/s7/fqc/apply", Name = "aidopS7FqcApply", Component = "/aidop/s7/fqc/fqcApplyList", Icon = "ele-DocumentAdd", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 5, Remark = "S7 成品报检(引用完工工单创建)" };
+        // 检验单录入(Phase 1E):从报检「录入检验」进入的独立隐藏路由 tab(不在侧栏显示)
+        yield return new SysMenu { Id = fqcBase + 7, Pid = fqcDirId, Title = "检验单录入", Path = "/aidop/s7/fqc/insp-detail", Name = "aidopS7FqcInspDetail", Component = "/aidop/s7/fqc/fqcInspDetail", Icon = "ele-EditPen", Type = MenuTypeEnum.Menu, IsHide = true, CreateTime = ct, OrderNo = 40, Remark = "S7 检验单录入(隐藏路由,列表跳转)" };
 
         // 生产入库管理 (1322000000024) 下 1 个三级
         const long productionReceiptDirId = 1322000000024L;