浏览代码

feat(s6): align IPQC inspection page with FQC layout | server 1.0.543 / web 2.4.399

S6 过程检验单(S6_PROCESS_INSPECTION 链)此前没有任何菜单入口 —— 用户必须先进
「生产指令单列表」,再从行操作打开一个 Drawer 才能完成录入/提交/审核/处置。
本批按 S7 FQC 的信息架构改成「独立 Tab + 独立详情页」,业务模型完全不动。

⚠️ 两条同名链务必区分:本批只动 S6_PROCESS_INSPECTION(工序级、有 S0 检规快照、
逐样本实测、节点 N1_SUBMIT/N2_SUP_REVIEW/N3_QE_DISPOSITION)。另一条
IPQC_INSPECTION(源 qms_gcjyd、单头级汇总)的菜单 1329016020001 与页面
processInspectionList.vue 一字未改 —— 两者中文名互相占用,改错会白做。

新增:
- 菜单 1.0.543.sql:列表 1329016020002「IPQC 检查单」(过程质量管理下,IsKeepAlive=1)
  + 详情 1329016020003(hidden child,path param /:id,**IsKeepAlive=0**)。
  租户面/角色面用子查询继承入口菜单 1329016020001(10 租户 / 23 角色),
  不硬编码 RoleId、不新建权限码、不改 SysMenuSeedData。
- ipqcBillList.vue:独立 Tab 列表(查询区/状态筛选/表格/操作列/分页/空态)
- ipqcBillDetail.vue:独立详情页(返回+单号+状态 → 基础信息 → 检规快照 → 检验结果
  → 项目明细 → 流程信息 → 处置结论 → 审核历史 → 操作区)
- components/IpqcBillItemPanel.vue:检验项目面板,从原 Drawer 抽出

退役:productionInstructionList.vue 的 el-drawer 整块删除(667 → 281 行)。
Agent 审计已证该 Drawer 不承担检查单之外的任何业务用途。行操作改为跳独立详情页,
`?billId=X` deep-link 原地 router.replace 到新页 —— 审批中心与旧书签继续可用,
未改 approvalFlow/ 下任何文件。prepare 的业务语义逐条保持不变。

**复制页面形态,不复制业务模型**:未引入任何 FQC 语义(无 j1..j50、无 FQC
DTO/表名/BizType,处置枚举仍是 S6 的 REWORK/CONCESSION/SCRAP/OTHER 字符串)。
字段全部来自 S6 现有 detail/state 两个接口,未为了「像 FQC」拼任何假值。

刻意不照抄 S7 的已知问题,改用 S5 IQC 的修正版:详情页 IsKeepAlive=0 +
watch(route.params.id)(S7 的 KeepAlive=1 且只有 onMounted,换单会显示上一单);
路由 path param + 正整数字符串校验(S7 用 Number(route.query.id));
状态 tag 按流程状态配色(S7 硬写 info 且直出英文枚举);
catch 不叠加 ElMessage,交 request.ts 拦截器弹后端真实 message。
同时保留 S6 自己更强的一处:审核历史 el-timeline + 退回意见回显(S7 详情页没有)。

Runtime(UATAdminA @ 838257186181189,chrome-devtools MCP):
U1 独立 Tab / U2 列表查询·筛选·重置·分页 / U3 独立 route(非 Drawer)/
U4 基础信息 / U5 检验项目与样本 / U6 录入+保存(后端判定回显 1:✓2:✓3:✓)/
U7 完成检验+提交审核 N1→N2 / U8 N2 只读+主管操作 / U9 退回→N1(意见可见、结果保留)/
U10 重新提交→N2 同一实例 848734317994053 / U11 返回保留查询条件 /
U12 直链刷新独立加载 / U13 权限沿用 SysRoleMenu 继承 / U14 console 0 error 0 warn。
详情页恰 2 个业务请求且并发(detail+state),无 N+1。

过程中修掉 3 处自身缺陷:列表 prop 直出导致 MDP 的字面量 'null' 漏到界面(X-003);
el-timeline-item size="small" 非法触发 8 条 Vue warning;两个新组件漏声明
`name`,导致 keep-alive include 匹配不上、返回时查询条件丢失。

🔴 登记两项后端缺口(本批不改后端,未新造前端判断):
1. state 无 canEdit。沿用原 Drawer 既有表达式(flowStatus 非 NotStarted 即锁录入),
   属搬运既有 authority。后果:主管退回后 UI 仍只读,而后端在 Running@N1 是允许
   saveResult 的 —— 该表达式比后端更严格。与退役前的 Drawer 行为一致,无回退,但未修复。
2. page 接口不能按 billId/检验状态/审核状态服务端过滤,也无独立检查单分页接口。
   故服务端分页仍按「待检工单」,三个检查单筛选在页内进行;筛选栏右侧把该语义
   写在界面上(本页命中 N / M,服务端共 T 条待检工单),不伪装成检查单总数。

验证:vue-tsc 584(与基线一致,净增 0);eslint 本批 4 文件 0 problem;
npm run build 成功;后端 dotnet build 0 Error;
迁移 1.0.543 status=Success verify_status=Success(6 语句 / 66 行)。
YY968XX 4 天之前
父节点
当前提交
34a6e65a39

+ 1 - 1
Web/package.json

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

+ 279 - 0
Web/src/views/aidop/s6/process-quality/components/IpqcBillItemPanel.vue

@@ -0,0 +1,279 @@
+<template>
+	<div class="item-panel">
+		<div class="panel-head">
+			<span class="panel-title">检验项目明细</span>
+			<el-tag v-if="canEdit" type="warning" size="small" effect="plain" disable-transitions>可录入</el-tag>
+			<el-tag v-else type="info" size="small" effect="plain" disable-transitions>只读</el-tag>
+			<span class="panel-hint">判定与整单结果一律以后端计算为准,前端仅作即时反馈</span>
+		</div>
+
+		<el-table :data="lines" border stripe size="small" row-key="id" max-height="460">
+			<el-table-column type="index" label="#" width="46" align="center" />
+			<el-table-column prop="processCode" label="工序" min-width="80" show-overflow-tooltip>
+				<template #default="{ row }">{{ dash(row.processCode) }}</template>
+			</el-table-column>
+			<el-table-column prop="inspectionItem" label="检验项目" min-width="120" show-overflow-tooltip />
+			<el-table-column label="录入类型" min-width="96" align="center">
+				<template #default="{ row }">
+					<el-tag size="small" :type="row.resultType === 'NUMERIC' ? 'success' : 'info'" disable-transitions>
+						{{ row.resultType === 'NUMERIC' ? '数值' : row.resultType === 'NON_NUMERIC' ? '非数值' : dash(row.resultType) }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column label="标准 / 上下限" min-width="140" class-name="wrap-cell">
+				<template #default="{ row }">
+					<span v-if="row.resultType === 'NUMERIC'">[{{ dash(row.lowerLimit) }} , {{ dash(row.upperLimit) }}]</span>
+					<span v-else>{{ dash(row.techStandard || row.specValue) }}</span>
+				</template>
+			</el-table-column>
+			<el-table-column prop="inspectionFrequency" label="频次" min-width="76" show-overflow-tooltip>
+				<template #default="{ row }">{{ dash(row.inspectionFrequency) }}</template>
+			</el-table-column>
+			<el-table-column label="实测录入" min-width="320">
+				<template #default="{ row }">
+					<template v-if="row.resultType === 'NUMERIC'">
+						<span v-for="i in sampleCountOf(row)" :key="i" class="sample-cell">
+							<el-input
+								v-model="entry[keyOf(row.id, i)]"
+								size="small"
+								style="width: 76px"
+								:placeholder="'样' + i"
+								:disabled="!canEdit"
+							/>
+							<el-tag v-if="liveJudge(row, i)" size="small" :type="judgeTag(liveJudge(row, i))" disable-transitions>
+								{{ shortJudge(liveJudge(row, i)) }}
+							</el-tag>
+						</span>
+					</template>
+					<template v-else>
+						<el-select
+							v-model="entry[keyOf(row.id, 1)]"
+							size="small"
+							style="width: 104px"
+							placeholder="OK / NG"
+							clearable
+							:disabled="!canEdit"
+						>
+							<el-option label="OK" value="OK" />
+							<el-option label="NG" value="NG" />
+						</el-select>
+						<el-tag v-if="liveJudge(row, 1)" size="small" :type="judgeTag(liveJudge(row, 1))" disable-transitions class="ml6">
+							{{ shortJudge(liveJudge(row, 1)) }}
+						</el-tag>
+					</template>
+				</template>
+			</el-table-column>
+			<el-table-column label="后端判定" min-width="150" class-name="wrap-cell">
+				<template #default="{ row }">
+					<span v-if="(row.samples || []).length === 0" class="muted">暂无</span>
+					<span v-for="s in row.samples || []" :key="s.sampleIndex" class="sample-cell">
+						<el-tag size="small" :type="judgeTag(s.judgement)" effect="plain" disable-transitions>
+							{{ s.sampleIndex }}:{{ shortJudge(s.judgement) || dash(s.judgement) }}
+						</el-tag>
+					</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="单项结果" min-width="108" align="center">
+				<template #default="{ row }">
+					<el-tag :type="judgeTag(row.itemResult)" size="small" disable-transitions>{{ resultText(row.itemResult) }}</el-tag>
+					<div class="item-status">{{ itemStatusText(row.itemStatus) }}</div>
+				</template>
+			</el-table-column>
+			<template #empty>
+				<el-empty description="暂无检验项目" :image-size="60" />
+			</template>
+		</el-table>
+
+		<div v-if="canEdit" class="panel-foot">
+			<span class="panel-hint">只提交实测值,不提交判定</span>
+			<el-button :loading="saving" @click="emit('save', buildSamples())">保存</el-button>
+			<el-button type="primary" :loading="completing" @click="emit('complete', buildSamples())">完成检验</el-button>
+		</div>
+		<div v-else class="panel-foot">
+			<span class="panel-hint">已提交审核,检验结果只读</span>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+/**
+ * S6 IPQC 检查单 · 检验项目明细面板。
+ *
+ * 从原「生产指令单列表」页 Drawer 里的录入表格抽出,供独立详情页复用 ——
+ * 抽的目的不是为了复用次数,而是不让详情页变成又一个几百行的巨型单文件
+ * (S7 FQC 的 fqcInspResultDetail.vue 592 行全在一处,本项目 coding-style 要求 200-400 行)。
+ *
+ * 两条 authority 约定(与 S5 IqcInspBillItemPanel 同款,勿改):
+ * - `canEdit` 是本组件**唯一**的编辑开关,组件内不按 currentNodeCode / flowStatus 自行推断;
+ * - 样本判定一律由后端算。组件里的 `liveJudge` 只是给录入者的即时反馈,
+ *   **不参与提交**(`buildSamples` 只吐实测值),后端判定单独在「后端判定」列回显。
+ *   两列并存是刻意的:录入时看到自己的即时反馈,保存后看到后端的权威判定,不一致立刻可见。
+ *
+ * 录入态由本组件自持(`entry`),从 `lines[].samples` 初始化,刷新可恢复;
+ * 父组件不需要知道 key 编码方式,只在 save/complete 时收到构造好的 samples。
+ */
+import { reactive, watch } from 'vue';
+import type { ProcessInspectionSnapshotLine, SaveResultSample } from '../../api/productionInstruction';
+
+const props = withDefaults(
+	defineProps<{
+		lines?: ProcessInspectionSnapshotLine[];
+		canEdit?: boolean;
+		saving?: boolean;
+		completing?: boolean;
+	}>(),
+	{ lines: () => [], canEdit: false, saving: false, completing: false }
+);
+
+const emit = defineEmits<{
+	(e: 'save', samples: SaveResultSample[]): void;
+	(e: 'complete', samples: SaveResultSample[]): void;
+}>();
+
+/** 录入模型:key = `${snapshotLineId}#${sampleIndex}`(1-based)→ 实测值字符串。 */
+const entry = reactive<Record<string, string>>({});
+
+function keyOf(lineId?: number, sampleIndex?: number) {
+	return `${lineId ?? 0}#${sampleIndex ?? 0}`;
+}
+
+function sampleCountOf(row: ProcessInspectionSnapshotLine) {
+	return row.resultType === 'NUMERIC' ? row.sampleCount || 1 : 1;
+}
+
+/** 从后端已录样本回填录入模型。lines 变化(含详情刷新)即重建,避免残留上一单的值。 */
+function syncEntryFromLines() {
+	for (const k of Object.keys(entry)) delete entry[k];
+	for (const line of props.lines ?? []) {
+		for (const s of line.samples ?? []) {
+			const val = line.resultType === 'NUMERIC' ? (s.actualNumeric != null ? String(s.actualNumeric) : '') : s.actualNonNumeric || '';
+			if (val !== '' && s.sampleIndex != null) entry[keyOf(line.id, s.sampleIndex)] = val;
+		}
+	}
+}
+watch(() => props.lines, syncEntryFromLines, { immediate: true, deep: false });
+
+/**
+ * 录入者的即时反馈,**非真值**:NUMERIC 闭区间;NON_NUMERIC OK→PASS / NG→FAIL;其余 INVALID。
+ * 与后端判定口径一致是有意的,但一旦两者显示不一致,以「后端判定」列为准。
+ */
+function liveJudge(row: ProcessInspectionSnapshotLine, i: number): string {
+	const raw = (entry[keyOf(row.id, i)] || '').trim();
+	if (!raw) return '';
+	if (row.resultType === 'NUMERIC') {
+		const n = Number(raw);
+		if (!Number.isFinite(n)) return 'INVALID';
+		const lo = row.lowerLimit != null && String(row.lowerLimit).trim() !== '' ? Number(row.lowerLimit) : null;
+		const up = row.upperLimit != null && String(row.upperLimit).trim() !== '' ? Number(row.upperLimit) : null;
+		if (lo === null && up === null) return 'INVALID';
+		if ((lo !== null && n < lo) || (up !== null && n > up)) return 'FAIL';
+		return 'PASS';
+	}
+	const u = raw.toUpperCase();
+	if (u === 'OK') return 'PASS';
+	if (u === 'NG') return 'FAIL';
+	return 'INVALID';
+}
+
+/** 只提交非空实测值;判定与整单结果由后端计算。 */
+function buildSamples(): SaveResultSample[] {
+	const out: SaveResultSample[] = [];
+	for (const line of props.lines ?? []) {
+		const cnt = sampleCountOf(line);
+		for (let i = 1; i <= cnt; i++) {
+			const raw = (entry[keyOf(line.id, i)] || '').trim();
+			if (!raw) continue;
+			out.push({ snapshotLineId: line.id as number, sampleIndex: i, actualValue: raw });
+		}
+	}
+	return out;
+}
+
+function judgeTag(result?: string): 'success' | 'danger' | 'info' | 'warning' {
+	if (result === 'PASS') return 'success';
+	if (result === 'FAIL') return 'danger';
+	if (result === 'INVALID') return 'warning';
+	return 'info';
+}
+
+function shortJudge(result?: string): string {
+	if (result === 'PASS') return '✓';
+	if (result === 'FAIL') return '✗';
+	if (result === 'INVALID') return '!';
+	return '';
+}
+
+function resultText(v?: string) {
+	if (v === 'PASS') return '合格';
+	if (v === 'FAIL') return '不合格';
+	if (v === 'PENDING') return '待判定';
+	if (v === 'INVALID') return '无效';
+	return dash(v);
+}
+
+function itemStatusText(v?: string) {
+	if (v === 'COMPLETED') return '已录满';
+	if (v === 'IN_PROGRESS') return '录入中';
+	if (v === 'NOT_STARTED') return '未开始';
+	return '';
+}
+
+/** 缺字段一律显示 —,不让 undefined / null / 字面量 'null' 漏到界面上。 */
+function dash(v?: unknown) {
+	if (v === null || v === undefined) return '—';
+	const s = String(v).trim();
+	if (s === '' || s === 'null' || s === 'undefined') return '—';
+	return s;
+}
+</script>
+
+<style scoped>
+.panel-head {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 8px;
+}
+.panel-title {
+	font-weight: 600;
+	font-size: 14px;
+}
+.panel-hint {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+.panel-foot {
+	margin-top: 12px;
+	display: flex;
+	align-items: center;
+	justify-content: flex-end;
+	gap: 10px;
+}
+.panel-foot .panel-hint {
+	margin-right: auto;
+}
+.sample-cell {
+	display: inline-flex;
+	align-items: center;
+	gap: 3px;
+	margin: 2px 6px 2px 0;
+}
+.ml6 {
+	margin-left: 6px;
+}
+.item-status {
+	font-size: 11px;
+	color: var(--el-text-color-secondary);
+	margin-top: 2px;
+}
+.muted {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+/* 与 S7 FQC 同款:标准/判定列用换行替代 tooltip 省略号,避免上下限被截断看不全 */
+:deep(.wrap-cell .cell) {
+	white-space: pre-wrap;
+	word-break: break-word;
+	line-height: 1.4;
+}
+</style>

+ 500 - 0
Web/src/views/aidop/s6/process-quality/ipqcBillDetail.vue

@@ -0,0 +1,500 @@
+<template>
+	<AidopDemoShell title="IPQC 检查单详情" subtitle="过程检验单(工序级)· 检验结果与审核流">
+		<div class="page-head">
+			<el-button size="small" @click="goBack">← 返回</el-button>
+			<span class="bill-no">{{ headTitle }}</span>
+			<el-tag v-if="review?.displayStatus" :type="flowTagType" size="small" effect="plain" disable-transitions>
+				{{ review.displayStatus }}
+			</el-tag>
+			<el-tag v-if="review?.qualityText || detail?.overallResult" :type="judgeTag(detail?.overallResult)" size="small" disable-transitions>
+				{{ review?.qualityText || detail?.overallResult }}
+			</el-tag>
+			<el-tag v-if="review?.currentNodeName" size="small" type="info" effect="plain" disable-transitions>
+				当前节点:{{ review.currentNodeName }}
+			</el-tag>
+		</div>
+
+		<div v-if="invalidId" class="detail-body">
+			<el-empty description="检验单 id 非法">
+				<el-button type="primary" @click="goBack">返回检查单列表</el-button>
+			</el-empty>
+		</div>
+
+		<div v-else class="detail-body" v-loading="detailLoading">
+			<el-descriptions :column="3" border size="small" title="基础信息">
+				<el-descriptions-item label="工单编号">{{ dash(detail?.workOrderNo) }}</el-descriptions-item>
+				<el-descriptions-item label="批次号">{{ dash(detail?.lotSerial) }}</el-descriptions-item>
+				<el-descriptions-item label="物料编码">{{ dash(detail?.itemCode) }}</el-descriptions-item>
+				<el-descriptions-item label="物料名称">{{ dash(detail?.itemName) }}</el-descriptions-item>
+				<el-descriptions-item label="规格型号">{{ dash(detail?.specification) }}</el-descriptions-item>
+				<el-descriptions-item label="单据状态">{{ dash(detail?.inspectionStatus) }}</el-descriptions-item>
+				<el-descriptions-item label="建单人">{{ dash(detail?.createdByName) }}</el-descriptions-item>
+				<el-descriptions-item label="建单时间">{{ fmtDateTime(detail?.createTime) }}</el-descriptions-item>
+				<el-descriptions-item label="检验单 id">{{ dash(detail?.billId) }}</el-descriptions-item>
+			</el-descriptions>
+
+			<el-descriptions class="mt8" :column="3" border size="small" title="检规信息(建单时冻结的快照)">
+				<el-descriptions-item label="检规编号">{{ dash(detail?.specCode) }}</el-descriptions-item>
+				<el-descriptions-item label="检规版本">{{ dash(detail?.specVersion) }}</el-descriptions-item>
+				<el-descriptions-item label="生效日期">{{ fmtDate(detail?.effectiveDate) }}</el-descriptions-item>
+			</el-descriptions>
+
+			<el-descriptions class="mt8" :column="3" border size="small" title="检验结果">
+				<el-descriptions-item label="整单状态">{{ overallStatusText }}</el-descriptions-item>
+				<el-descriptions-item label="整单结果">
+					<el-tag v-if="detail?.overallResult" :type="judgeTag(detail.overallResult)" size="small" disable-transitions>
+						{{ review?.qualityText || detail.overallResult }}
+					</el-tag>
+					<span v-else>—</span>
+				</el-descriptions-item>
+				<el-descriptions-item label="检验项目数">{{ (detail?.lines || []).length }}</el-descriptions-item>
+			</el-descriptions>
+
+			<div class="mt12">
+				<IpqcBillItemPanel
+					:lines="detail?.lines || []"
+					:can-edit="canEdit"
+					:saving="saving"
+					:completing="completing"
+					@save="onSave"
+					@complete="onComplete"
+				/>
+			</div>
+
+			<el-descriptions class="mt12" :column="3" border size="small" title="流程信息" v-loading="reviewLoading">
+				<el-descriptions-item label="流程状态">{{ dash(review?.displayStatus) }}</el-descriptions-item>
+				<el-descriptions-item label="当前节点">{{ dash(review?.currentNodeName) }}</el-descriptions-item>
+				<el-descriptions-item label="当前处理角色">{{ dash(review?.currentAssigneeRole) }}</el-descriptions-item>
+				<el-descriptions-item label="流程实例">{{ dash(review?.instanceId) }}</el-descriptions-item>
+				<el-descriptions-item label="说明" :span="2">{{ dash(review?.message) }}</el-descriptions-item>
+			</el-descriptions>
+
+			<el-descriptions v-if="review?.disposition" class="mt8" :column="3" border size="small" title="质量处置结论">
+				<el-descriptions-item label="处置类型">
+					<el-tag type="warning" size="small" disable-transitions>
+						{{ review.disposition.dispositionTypeText || review.disposition.dispositionType }}
+					</el-tag>
+				</el-descriptions-item>
+				<el-descriptions-item label="处置人">{{ dash(review.disposition.operatorName) }}</el-descriptions-item>
+				<el-descriptions-item label="提交时间">{{ fmtDateTime(review.disposition.submittedAt) }}</el-descriptions-item>
+				<el-descriptions-item label="责任部门">{{ dash(review.disposition.responsibilityDept) }}</el-descriptions-item>
+				<el-descriptions-item label="责任人">{{ dash(review.disposition.responsibleUser) }}</el-descriptions-item>
+				<el-descriptions-item label="处置数量">{{ dash(review.disposition.dispositionQty) }}</el-descriptions-item>
+				<el-descriptions-item label="处置意见" :span="3">{{ dash(review.disposition.dispositionOpinion) }}</el-descriptions-item>
+			</el-descriptions>
+
+			<div class="mt12 hist-block">
+				<div class="hist-title">审核历史</div>
+				<el-timeline v-if="(review?.history || []).length">
+					<!-- size 只接受 normal / large,不要写 small(会触发 prop 校验 warning) -->
+					<el-timeline-item
+						v-for="(h, i) in review?.history || []"
+						:key="i"
+						:timestamp="fmtDateTime(h.time)"
+						placement="top"
+						size="normal"
+					>
+						<span class="hist-node">{{ dash(h.nodeName) }}</span>
+						· {{ dash(h.actionText) }}
+						<span class="hist-op">{{ dash(h.operatorName) }}</span>
+						<div v-if="h.comment" class="hist-comment">意见:{{ h.comment }}</div>
+					</el-timeline-item>
+				</el-timeline>
+				<el-empty v-else description="暂无审核历史" :image-size="60" />
+			</div>
+
+			<div class="mt12 flow-op" v-if="hasAnyAction">
+				<div class="flow-op-title">操作</div>
+				<div class="flow-op-row">
+					<el-button v-if="review?.canSubmitReview" type="primary" :loading="flowSubmitting" @click="onSubmitReview">
+						提交审核
+					</el-button>
+					<el-button v-if="review?.canSupervisorApprove" type="success" :loading="flowSubmitting" @click="onSupervisorApprove">
+						主管通过
+					</el-button>
+					<el-button v-if="review?.canSupervisorReturn" :loading="flowSubmitting" @click="onSupervisorReturn">退回</el-button>
+				</div>
+				<div v-if="review?.canSubmitDisposition" class="flow-op-row mt8">
+					<el-select v-model="dispType" size="small" style="width: 168px" placeholder="处置类型">
+						<el-option label="返工 REWORK" value="REWORK" />
+						<el-option label="让步 CONCESSION" value="CONCESSION" />
+						<el-option label="报废 SCRAP" value="SCRAP" />
+						<el-option label="其它 OTHER" value="OTHER" />
+					</el-select>
+					<el-input v-model="dispOpinion" size="small" style="width: 320px" placeholder="处置意见(必填)" />
+					<el-button type="warning" :loading="flowSubmitting" @click="onQeDisposition">提交处置</el-button>
+				</div>
+			</div>
+
+			<div class="mt12" v-if="review?.isCompleted">
+				<el-tag :type="review?.flowStatus === 'Approved' ? 'success' : 'info'" size="small" disable-transitions>
+					{{ review?.flowStatus === 'Approved' ? '审核已闭环(CLOSED)' : `流程已结束(${review?.flowStatus})` }}
+				</el-tag>
+			</div>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS6IpqcBillDetail">
+/**
+ * S6 IPQC 检查单 · 独立详情页(`S6_PROCESS_INSPECTION` 链)。
+ *
+ * ⚠️ 与「过程检验单列表」不是同一条链。本页对应 `ado_s6_process_inspection_bill`
+ * (工序级、有 S0 检规快照、逐样本实测),节点 N1_SUBMIT / N2_SUP_REVIEW / N3_QE_DISPOSITION。
+ * 另一条 `IpqcInspection*` 链(源 `qms_gcjyd`、单头级汇总、节点 N1_INSPECT/.../N3_QUALITY_DISPOSITION)
+ * 在 `process-quality/processInspectionList.vue`,与本页无共享代码 —— 改错文件会白做。
+ *
+ * 信息架构对齐 S7 FQC 详情页(整页 + 顶部返回/单号/状态 + 区块化 descriptions + 项目明细 + 流程 + 操作),
+ * 但业务模型、接口、字段、枚举全部沿用 S6 自己的,**不引入任何 FQC 语义**
+ * (无 j1..j50、无 FQC DTO/表名/BizType、处置枚举用 S6 的 REWORK/CONCESSION/SCRAP/OTHER 字符串)。
+ *
+ * 刻意**不照抄** S7 的三处实现(改用 S5 IQC 已修正的做法):
+ * - 路由用 path param 且菜单 `IsKeepAlive=0`。详情页是强状态页(可录入 → 只读 → 退回 → 可录入),
+ *   S7 的 `IsKeepAlive=1` + 只有 onMounted 会在换单时显示上一单数据;本页另加 `watch(route.params.id)` 兜底。
+ * - 状态 tag 按流程状态配色(S7 硬写 type="info" 且直出英文枚举)。
+ * - 保存/动作失败不再叠加 ElMessage —— 交给 `request.ts` 拦截器统一弹后端真实 message,
+ *   避免同时弹出业务文案与 axios 技术文案。
+ *
+ * 🔴 已登记的后端缺口(本批不改后端):`/api/S6ProcessInspectionReview/state` **没有 `canEdit`**。
+ * 本页沿用原 Drawer 既有的同一条表达式(`flowStatus` 非 NotStarted 即锁录入),
+ * 属**搬运既有 authority,不是新造第二套判断**。该字段补齐后应直接换成 `canEdit`。
+ */
+import { computed, onActivated, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import IpqcBillItemPanel from './components/IpqcBillItemPanel.vue';
+import {
+	fetchProcessInspectionDetail,
+	saveProcessInspectionResult,
+	completeProcessInspection,
+	fetchReviewState,
+	submitReview,
+	reviewSupervisorApprove,
+	reviewSupervisorReturn,
+	reviewQeDisposition,
+	type ProcessInspectionDetail,
+	type ReviewState,
+	type SaveResultSample,
+} from '../api/productionInstruction';
+
+const IPQC_LIST_PATH = '/aidop/s6/process-quality/ipqc-bill-list';
+
+const route = useRoute();
+const router = useRouter();
+
+const detail = ref<ProcessInspectionDetail | null>(null);
+const review = ref<ReviewState | null>(null);
+const detailLoading = ref(false);
+const reviewLoading = ref(false);
+const saving = ref(false);
+const completing = ref(false);
+const flowSubmitting = ref(false);
+const dispType = ref('');
+const dispOpinion = ref('');
+
+/**
+ * 路由参数先按「正整数字符串」校验再转数字。
+ * `ado_s6_process_inspection_bill.id` 是自增小整数(当前量级为个位数),
+ * 不是雪花号,故 Number() 安全;校验是为了不把 `/detail/abc` 之类当成 0 去打接口。
+ */
+const billIdRaw = computed(() => String(route.params.id ?? '').trim());
+const invalidId = computed(() => !/^\d+$/.test(billIdRaw.value) || Number(billIdRaw.value) <= 0);
+const billId = computed(() => (invalidId.value ? 0 : Number(billIdRaw.value)));
+
+const headTitle = computed(() => {
+	const wo = detail.value?.workOrderNo;
+	return wo ? `IPQC 检查单 · ${wo}` : `IPQC 检查单 #${billIdRaw.value || '—'}`;
+});
+
+/** 🔴 见文件头:后端无 canEdit,此处沿用原 Drawer 的同一表达式,未新造判断。 */
+const canEdit = computed(() => {
+	const fs = review.value?.flowStatus;
+	return !(fs && fs !== 'NotStarted');
+});
+
+const hasAnyAction = computed(
+	() =>
+		!!review.value &&
+		(review.value.canSubmitReview || review.value.canSupervisorApprove || review.value.canSupervisorReturn || review.value.canSubmitDisposition)
+);
+
+const flowTagType = computed<'success' | 'danger' | 'warning' | 'info'>(() => {
+	switch (review.value?.flowStatus) {
+		case 'Approved':
+			return 'success';
+		case 'Rejected':
+		case 'Terminated':
+		case 'Cancelled':
+			return 'danger';
+		case 'Running':
+			return 'warning';
+		default:
+			return 'info';
+	}
+});
+
+const overallStatusText = computed(() => {
+	switch (detail.value?.overallStatus) {
+		case 'COMPLETED':
+			return '已完成检验';
+		case 'IN_PROGRESS':
+			return '检验中';
+		case 'NOT_STARTED':
+			return '未开始';
+		default:
+			return dash(detail.value?.overallStatus);
+	}
+});
+
+function resetPageState() {
+	detail.value = null;
+	review.value = null;
+	dispType.value = '';
+	dispOpinion.value = '';
+}
+
+async function loadReview() {
+	if (!billId.value) return;
+	reviewLoading.value = true;
+	try {
+		review.value = await fetchReviewState(billId.value);
+	} catch {
+		review.value = null;
+	} finally {
+		reviewLoading.value = false;
+	}
+}
+
+/** 详情与流程态可并发;两者都不依赖对方的结果。 */
+async function loadPage() {
+	if (invalidId.value) {
+		resetPageState();
+		return;
+	}
+	detailLoading.value = true;
+	try {
+		const [d] = await Promise.all([fetchProcessInspectionDetail(billId.value), loadReview()]);
+		detail.value = d;
+	} catch {
+		detail.value = null;
+	} finally {
+		detailLoading.value = false;
+	}
+}
+
+async function reloadAfterAction() {
+	if (!billId.value) return;
+	try {
+		detail.value = await fetchProcessInspectionDetail(billId.value);
+	} catch {
+		/* 详情刷新失败不覆盖已有展示,错误由拦截器提示 */
+	}
+	await loadReview();
+}
+
+async function onSave(samples: SaveResultSample[]) {
+	if (!billId.value || saving.value) return;
+	saving.value = true;
+	try {
+		await saveProcessInspectionResult(billId.value, samples);
+		await reloadAfterAction();
+		ElMessage.success('已保存');
+	} catch {
+		// 后端真实 message 由 request.ts 拦截器统一提示,此处不叠加
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function onComplete(samples: SaveResultSample[]) {
+	if (!billId.value || completing.value) return;
+	completing.value = true;
+	try {
+		await saveProcessInspectionResult(billId.value, samples);
+		const r = await completeProcessInspection(billId.value);
+		await reloadAfterAction();
+		ElMessage.success(`已完成检验:整单 ${r?.overallResult || detail.value?.overallResult || '—'}`);
+	} catch (e: any) {
+		const msg = String(e?.response?.data?.message || e?.message || '');
+		if (msg.includes('INSPECTION_INCOMPLETE')) ElMessage.warning('尚有检验项未按频次录满,无法完成检验。');
+		await reloadAfterAction();
+	} finally {
+		completing.value = false;
+	}
+}
+
+async function onSubmitReview() {
+	if (!billId.value || flowSubmitting.value) return;
+	flowSubmitting.value = true;
+	try {
+		review.value = await submitReview(billId.value);
+		ElMessage.success('已提交审核');
+		await reloadAfterAction();
+	} catch {
+		await loadReview();
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onSupervisorApprove() {
+	if (!billId.value || flowSubmitting.value) return;
+	flowSubmitting.value = true;
+	try {
+		review.value = await reviewSupervisorApprove(billId.value);
+		ElMessage.success('主管审核通过');
+		await reloadAfterAction();
+	} catch {
+		await loadReview();
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onSupervisorReturn() {
+	if (!billId.value || flowSubmitting.value) return;
+	let comment: string;
+	try {
+		({ value: comment } = await ElMessageBox.prompt('退回意见(必填)', '退回', { inputPattern: /\S+/, inputErrorMessage: '意见必填' }));
+	} catch {
+		return; // 用户取消
+	}
+	flowSubmitting.value = true;
+	try {
+		review.value = await reviewSupervisorReturn(billId.value, comment);
+		ElMessage.success('已退回');
+		await reloadAfterAction();
+	} catch {
+		await loadReview();
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+async function onQeDisposition() {
+	if (!billId.value || flowSubmitting.value) return;
+	if (!dispType.value) return ElMessage.warning('请选择处置类型');
+	if (!dispOpinion.value.trim()) return ElMessage.warning('处置意见必填');
+	flowSubmitting.value = true;
+	try {
+		review.value = await reviewQeDisposition({
+			billId: billId.value,
+			dispositionType: dispType.value,
+			dispositionOpinion: dispOpinion.value.trim(),
+		});
+		ElMessage.success('已提交质量处置');
+		dispType.value = '';
+		dispOpinion.value = '';
+		await reloadAfterAction();
+	} catch {
+		await loadReview();
+	} finally {
+		flowSubmitting.value = false;
+	}
+}
+
+function goBack() {
+	if (window.history.length > 1) router.back();
+	else router.push(IPQC_LIST_PATH);
+}
+
+function judgeTag(result?: string): 'success' | 'danger' | 'info' | 'warning' {
+	if (result === 'PASS') return 'success';
+	if (result === 'FAIL') return 'danger';
+	if (result === 'INVALID') return 'warning';
+	return 'info';
+}
+
+function dash(v?: unknown) {
+	if (v === null || v === undefined) return '—';
+	const s = String(v).trim();
+	if (s === '' || s === 'null' || s === 'undefined') return '—';
+	return s;
+}
+
+function fmtDate(v?: string) {
+	if (!v) return '—';
+	const s = String(v);
+	return s.length >= 10 ? s.slice(0, 10) : s;
+}
+
+function fmtDateTime(v?: string) {
+	if (!v) return '—';
+	return String(v).replace('T', ' ').slice(0, 19);
+}
+
+onMounted(loadPage);
+// 菜单 IsKeepAlive=0,理论上每次进入都会重建;onActivated 与 watch 是双保险 ——
+// 直接改 URL 的 id、或将来有人把 KeepAlive 打开时,都不会显示上一单的数据。
+onActivated(loadPage);
+watch(
+	() => route.params.id,
+	() => {
+		resetPageState();
+		loadPage();
+	}
+);
+</script>
+
+<style scoped>
+.page-head {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	flex-wrap: wrap;
+	margin-bottom: 12px;
+}
+.bill-no {
+	font-weight: 600;
+	font-size: 16px;
+}
+.detail-body {
+	min-height: 160px;
+}
+.mt8 {
+	margin-top: 8px;
+}
+.mt12 {
+	margin-top: 12px;
+}
+.hist-block {
+	padding: 12px;
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+	background: var(--el-fill-color-light);
+}
+.hist-title {
+	font-weight: 600;
+	font-size: 14px;
+	margin-bottom: 10px;
+}
+.hist-node {
+	font-weight: 600;
+}
+.hist-op {
+	color: var(--el-color-primary);
+	margin-left: 8px;
+}
+.hist-comment {
+	margin-top: 4px;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+.flow-op {
+	padding: 12px;
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+	background: var(--el-fill-color-light);
+}
+.flow-op-title {
+	font-weight: 600;
+	margin-bottom: 10px;
+}
+.flow-op-row {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+</style>

+ 398 - 0
Web/src/views/aidop/s6/process-quality/ipqcBillList.vue

@@ -0,0 +1,398 @@
+<template>
+	<AidopDemoShell title="IPQC 检查单" subtitle="过程检验单(工序级)· 检验与审核入口">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="工单编号">
+				<el-input v-model="query.workOrderNo" clearable placeholder="工单编号" style="width: 180px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="批次号">
+				<el-input v-model="query.lotSerial" clearable placeholder="批次号" style="width: 160px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.itemCode" clearable placeholder="物料编码" style="width: 160px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="规格型号">
+				<el-input v-model="query.specification" clearable placeholder="规格型号" style="width: 160px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="开工日期">
+				<el-date-picker
+					v-model="orderDateRange"
+					type="daterange"
+					value-format="YYYY-MM-DD"
+					range-separator="至"
+					start-placeholder="开始"
+					end-placeholder="结束"
+					style="width: 240px"
+				/>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+				<el-button :loading="loading" @click="loadList">刷新</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="filter-bar mb12">
+			<span class="filter-label">检查单范围</span>
+			<el-radio-group v-model="billScope" size="small">
+				<el-radio-button value="HAS_BILL">已建检查单</el-radio-button>
+				<el-radio-button value="NO_BILL">待建检查单</el-radio-button>
+				<el-radio-button value="ALL">全部</el-radio-button>
+			</el-radio-group>
+			<span class="filter-label ml16">检验状态</span>
+			<el-select v-model="inspFilter" size="small" style="width: 130px">
+				<el-option label="全部" value="ALL" />
+				<el-option label="待录入" value="NOT_STARTED" />
+				<el-option label="检验中" value="IN_PROGRESS" />
+				<el-option label="已完成" value="COMPLETED" />
+			</el-select>
+			<span class="filter-label ml16">审核状态</span>
+			<el-select v-model="flowFilter" size="small" style="width: 130px">
+				<el-option label="全部" value="ALL" />
+				<el-option label="未提交" value="NONE" />
+				<el-option label="审核中" value="1" />
+				<el-option label="已关闭" value="2" />
+			</el-select>
+			<!-- 分页语义必须说清:服务端按「待检工单」分页,检查单筛选在本页内进行 -->
+			<span class="filter-hint">
+				本页命中 <b>{{ viewRows.length }}</b> / {{ rows.length }} 条,服务端共 {{ total }} 条待检工单
+			</span>
+		</div>
+
+		<el-table :data="viewRows" row-key="id" v-loading="loading" border stripe size="small">
+			<el-table-column type="index" label="#" width="46" align="center" />
+			<el-table-column prop="workOrderNo" label="工单编号" min-width="140" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ dash(row.workOrderNo) }}</template>
+			</el-table-column>
+			<el-table-column prop="lotSerial" label="批次号" min-width="120" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ dash(row.lotSerial) }}</template>
+			</el-table-column>
+			<el-table-column prop="itemCode" label="物料编码" min-width="120" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ dash(row.itemCode) }}</template>
+			</el-table-column>
+			<el-table-column prop="itemName" label="物料名称" min-width="180" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ dash(row.itemName) }}</template>
+			</el-table-column>
+			<el-table-column prop="specification" label="规格型号" min-width="120" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ dash(row.specification) }}</template>
+			</el-table-column>
+			<el-table-column prop="orderQty" label="订单数量" min-width="100" align="right" resizable>
+				<template #default="{ row }">{{ fmtNum(row.orderQty) }}</template>
+			</el-table-column>
+			<el-table-column prop="completedQty" label="报工数量" min-width="100" align="right" resizable>
+				<template #default="{ row }">{{ fmtNum(row.completedQty) }}</template>
+			</el-table-column>
+			<el-table-column label="检验状态" min-width="100" align="center" resizable>
+				<template #default="{ row }">
+					<el-tag :type="inspTag(row)" size="small" effect="plain" disable-transitions>{{ inspText(row) }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column label="检验结果" min-width="90" align="center" resizable>
+				<template #default="{ row }">
+					<el-tag v-if="row.overallResult" :type="resultTag(row)" size="small" disable-transitions>{{ resultText(row) }}</el-tag>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="审核状态" min-width="100" align="center" resizable>
+				<template #default="{ row }">{{ flowText(row) }}</template>
+			</el-table-column>
+			<el-table-column prop="orderDate" label="开工日期" min-width="110" align="center" resizable>
+				<template #default="{ row }">{{ fmtDate(row.orderDate) }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="120" align="center" fixed="right">
+				<template #default="{ row }">
+					<el-button link type="primary" :loading="opening === row.workOrderNo" @click="openBill(row)">
+						{{ actionText(row) }}
+					</el-button>
+				</template>
+			</el-table-column>
+			<template #empty>
+				<el-empty :description="rows.length ? '当前筛选下暂无检查单' : '暂无数据'" />
+			</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="onSizeChange"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts" name="aidopS6IpqcBillList">
+/**
+ * S6 IPQC 检查单列表(`S6_PROCESS_INSPECTION` 链)—— 独立 Tab 入口。
+ *
+ * ⚠️ 与同目录的 `processInspectionList.vue` 不是同一条链:那个是 `IPQC_INSPECTION`
+ * (源 `qms_gcjyd`、单头级汇总判定);本页对应 `ado_s6_process_inspection_bill`
+ * (工序级、有 S0 检规快照、逐样本实测)。两条链共享「过程检验」这个中文名,但数据与流程完全独立。
+ *
+ * 本页之前不存在:检查单只能从「生产指令单列表」的行操作打开 Drawer。现在改为独立 Tab +
+ * 点击进入独立详情页,信息架构对齐 S7 FQC(查询区 / 状态筛选 / 列表 / 操作列 / 分页 / 空态)。
+ *
+ * 🔴 已登记的后端缺口(本批不改后端):`/api/S6ProductionInstruction/page` 的服务端过滤项只有
+ * 工单号 / 批次 / 物料 / 规格 / 生产状态 / 开工日期,**不能按 billId、检验状态、审核状态过滤**,
+ * 也没有独立的「检查单分页」接口。因此:
+ *   - 服务端分页仍是按「待检工单」分页(`total` 即工单总数,不是检查单总数);
+ *   - 检查单范围 / 检验状态 / 审核状态三个筛选在**本页内**进行,只影响当页命中条数。
+ * 筛选栏右侧把这个语义写在界面上(本页命中 N / M,服务端共 T 条待检工单),不伪装成检查单总数。
+ * 后端补一个带 billId / 状态过滤的检查单分页接口后,此处应改为服务端筛选。
+ *
+ * 列表字段全部来自 `ProductionInstructionRow` 真实字段(含 billId / inspectionResultStatus /
+ * overallResult / flowStatusRaw),未为了「像 FQC」拼任何假值。
+ */
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import {
+	fetchProductionInstructionPage,
+	prepareProcessInspection,
+	type ProductionInstructionRow,
+} from '../api/productionInstruction';
+
+const DETAIL_PATH = '/aidop/s6/process-quality/ipqc-bill-detail';
+
+const router = useRouter();
+
+const loading = ref(false);
+const rows = ref<ProductionInstructionRow[]>([]);
+const total = ref(0);
+const opening = ref('');
+
+/** 默认只看已建检查单 —— 这是「检查单」Tab,不是生产指令 Tab。 */
+const billScope = ref<'HAS_BILL' | 'NO_BILL' | 'ALL'>('HAS_BILL');
+const inspFilter = ref<'ALL' | 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED'>('ALL');
+const flowFilter = ref<'ALL' | 'NONE' | '1' | '2'>('ALL');
+
+const query = reactive({
+	page: 1,
+	pageSize: 20,
+	workOrderNo: '',
+	lotSerial: '',
+	itemCode: '',
+	specification: '',
+});
+const orderDateRange = ref<[string, string] | null>(null);
+
+const viewRows = computed(() =>
+	rows.value.filter((r) => {
+		if (billScope.value === 'HAS_BILL' && !r.billId) return false;
+		if (billScope.value === 'NO_BILL' && r.billId) return false;
+		if (inspFilter.value !== 'ALL') {
+			const s = r.billId ? r.inspectionResultStatus || 'NOT_STARTED' : 'NOT_STARTED';
+			if (s !== inspFilter.value) return false;
+		}
+		if (flowFilter.value !== 'ALL') {
+			const f = r.flowStatusRaw;
+			if (flowFilter.value === 'NONE' && f != null) return false;
+			if (flowFilter.value !== 'NONE' && String(f ?? '') !== flowFilter.value) return false;
+		}
+		return true;
+	})
+);
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const res = await fetchProductionInstructionPage({
+			page: query.page,
+			pageSize: query.pageSize,
+			workOrderNo: query.workOrderNo.trim() || undefined,
+			lotSerial: query.lotSerial.trim() || undefined,
+			itemCode: query.itemCode.trim() || undefined,
+			specification: query.specification.trim() || undefined,
+			orderDateStart: orderDateRange.value?.[0] || undefined,
+			orderDateEnd: orderDateRange.value?.[1] || undefined,
+		});
+		rows.value = res?.list ?? [];
+		total.value = res?.total ?? 0;
+	} catch {
+		rows.value = [];
+		total.value = 0;
+		// 后端真实 message 由 request.ts 拦截器统一提示
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.workOrderNo = '';
+	query.lotSerial = '';
+	query.itemCode = '';
+	query.specification = '';
+	orderDateRange.value = null;
+	billScope.value = 'HAS_BILL';
+	inspFilter.value = 'ALL';
+	flowFilter.value = 'ALL';
+	query.page = 1;
+	loadList();
+}
+
+function onSizeChange() {
+	query.page = 1;
+	loadList();
+}
+
+/**
+ * 已有检查单 → 直接进详情页。
+ * 尚未建单 → 先调 prepare(后端自动唯一解析 S0 检规,幂等生成检验单 + 冻结快照),
+ * MATCHED 才跳;其余状态明确阻断、不建单 —— 与原 Drawer 入口行为一致,未改变业务语义。
+ */
+async function openBill(row: ProductionInstructionRow) {
+	if (row.billId) {
+		router.push(`${DETAIL_PATH}/${row.billId}`);
+		return;
+	}
+	const wo = row.workOrderNo;
+	if (!wo || opening.value) return;
+	opening.value = wo;
+	try {
+		const r = await prepareProcessInspection(wo);
+		if (r?.status === 'MATCHED' && r.billId) {
+			router.push(`${DETAIL_PATH}/${r.billId}`);
+			return;
+		}
+		const title =
+			r?.status === 'NO_MATCH'
+				? '无法生成过程检验单'
+				: r?.status === 'AMBIGUOUS'
+					? '检规存在歧义'
+					: r?.status === 'INVALID_SPEC'
+						? '检规无效'
+						: '无法生成过程检验单';
+		const fallback =
+			r?.status === 'NO_MATCH'
+				? '当前物料未配置有效过程检验规范,请维护 S0 过程检验规范。'
+				: r?.status === 'AMBIGUOUS'
+					? '当前物料存在多个同时有效过程检验规范,请修正 S0 规范配置。'
+					: r?.status === 'INVALID_SPEC'
+						? '过程检验规范配置无效,请检查版本/生效日期/明细。'
+						: '无法解析过程检验规范。';
+		await ElMessageBox.alert(r?.message || fallback, title, { type: 'warning' });
+	} catch {
+		ElMessage.error('过程检验准备失败');
+	} finally {
+		opening.value = '';
+	}
+}
+
+// ── 列表状态映射(确定性映射,非语义猜测)──
+function inspText(row: ProductionInstructionRow) {
+	if (!row.billId) return '未建单';
+	if (row.inspectionResultStatus === 'COMPLETED') return '已完成';
+	if (row.inspectionResultStatus === 'IN_PROGRESS') return '检验中';
+	return '待录入';
+}
+function inspTag(row: ProductionInstructionRow): 'success' | 'warning' | 'info' {
+	if (!row.billId) return 'info';
+	if (row.inspectionResultStatus === 'COMPLETED') return 'success';
+	if (row.inspectionResultStatus === 'IN_PROGRESS') return 'warning';
+	return 'info';
+}
+function resultText(row: ProductionInstructionRow) {
+	return row.overallResult === 'PASS' ? '合格' : row.overallResult === 'FAIL' ? '不合格' : '—';
+}
+function resultTag(row: ProductionInstructionRow): 'success' | 'danger' | 'info' {
+	return row.overallResult === 'PASS' ? 'success' : row.overallResult === 'FAIL' ? 'danger' : 'info';
+}
+function flowText(row: ProductionInstructionRow) {
+	if (!row.billId) return '—';
+	switch (row.flowStatusRaw) {
+		case 1:
+			return '审核中';
+		case 2:
+			return '已关闭';
+		case 3:
+			return '已驳回';
+		case 4:
+			return '已撤回';
+		case 5:
+			return '已终止';
+		default:
+			return row.inspectionResultStatus === 'COMPLETED' ? '待提交审核' : '—';
+	}
+}
+function actionText(row: ProductionInstructionRow) {
+	if (!row.billId) return '开始检验';
+	switch (row.flowStatusRaw) {
+		case 1:
+			return '查看审核';
+		case 2:
+			return '查看结果';
+		case 3:
+		case 4:
+			return '重新提交';
+		default:
+			return row.inspectionResultStatus === 'COMPLETED' ? '查看/提交' : '继续检验';
+	}
+}
+
+/**
+ * 缺字段一律显示 —。
+ * 必须显式拦 'null' / 'undefined' 字面量:MDP 标准层存在把源侧 JSON null 落成
+ * 长度 4 的字符串 'null' 的既有问题(跨模块 X-003),直出 prop 会把它渲染到界面上。
+ */
+function dash(v?: unknown) {
+	if (v === null || v === undefined) return '—';
+	const s = String(v).trim();
+	if (s === '' || s === 'null' || s === 'undefined') return '—';
+	return s;
+}
+function fmtDate(v?: string) {
+	if (!v) return '—';
+	const s = String(v);
+	return s.length >= 10 ? s.slice(0, 10) : s;
+}
+function fmtNum(v?: number) {
+	if (v === null || v === undefined) return '—';
+	return Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 });
+}
+
+onMounted(loadList);
+// 菜单 IsKeepAlive=1:从详情页返回时保留分页与查询条件,但数据重新拉一次(状态可能已被改动)
+onActivated(loadList);
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+.filter-bar {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 8px;
+	padding: 8px 12px;
+	background: var(--el-fill-color-light);
+	border: 1px solid var(--el-border-color-lighter);
+	border-radius: 4px;
+}
+.filter-label {
+	font-size: 13px;
+	color: var(--el-text-color-regular);
+}
+.ml16 {
+	margin-left: 16px;
+}
+.filter-hint {
+	margin-left: auto;
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+</style>

+ 29 - 415
Web/src/views/aidop/s6/production-record/productionInstructionList.vue

@@ -93,245 +93,38 @@
 			/>
 		</div>
 
-		<el-drawer v-model="detailVisible" title="过程检验单" size="62%" :destroy-on-close="true">
-			<div v-loading="detailLoading">
-				<el-descriptions :column="3" border size="small" title="检验单信息">
-					<el-descriptions-item label="工单编号">{{ detail?.workOrderNo || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="批次号">{{ detail?.lotSerial || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="物料编码">{{ detail?.itemCode || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="物料名称">{{ detail?.itemName || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="规格型号">{{ detail?.specification || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="检规编号">{{ detail?.specCode || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="检规版本">{{ detail?.specVersion || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="生效日期">{{ detail?.effectiveDate || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="整单状态">{{ detail?.overallStatus || '—' }}</el-descriptions-item>
-					<el-descriptions-item label="整单结果">
-						<el-tag v-if="detail?.overallResult" :type="judgeTag(detail.overallResult)" size="small" disable-transitions>{{ detail.overallResult }}</el-tag>
-						<span v-else>—</span>
-					</el-descriptions-item>
-				</el-descriptions>
-
-				<div class="snap-title">检验录入 · 自动判定(无人工选检规;判定以后端为准)</div>
-				<el-table :data="detail?.lines || []" border stripe size="small" max-height="400" row-key="id">
-					<el-table-column type="index" label="#" width="42" align="center" />
-					<el-table-column prop="processCode" label="工序" min-width="66" show-overflow-tooltip />
-					<el-table-column prop="inspectionItem" label="检验项目" min-width="110" show-overflow-tooltip />
-					<el-table-column label="录入类型" min-width="96" align="center">
-						<template #default="{ row }">
-							<el-tag size="small" :type="row.resultType === 'NUMERIC' ? 'success' : 'info'" disable-transitions>{{ row.resultType || '—' }}</el-tag>
-						</template>
-					</el-table-column>
-					<el-table-column label="标准 / 上下限" min-width="130">
-						<template #default="{ row }">
-							<span v-if="row.resultType === 'NUMERIC'">[{{ row.lowerLimit ?? '' }} , {{ row.upperLimit ?? '' }}]</span>
-							<span v-else>{{ row.techStandard || row.specValue || '—' }}</span>
-						</template>
-					</el-table-column>
-					<el-table-column prop="inspectionFrequency" label="频次" min-width="70" show-overflow-tooltip />
-					<el-table-column label="实测录入" min-width="300">
-						<template #default="{ row }">
-							<template v-if="row.resultType === 'NUMERIC'">
-								<span v-for="i in (row.sampleCount || 1)" :key="i" class="sample-cell">
-									<el-input v-model="entry[key(row.id, i)]" size="small" style="width: 74px" :placeholder="'样' + i" :disabled="reviewLocked" />
-									<el-tag v-if="sampleJudge(row, i)" size="small" :type="judgeTag(sampleJudge(row, i))" disable-transitions>{{ shortJ(sampleJudge(row, i)) }}</el-tag>
-								</span>
-							</template>
-							<template v-else>
-								<el-select v-model="entry[key(row.id, 1)]" size="small" style="width: 100px" placeholder="OK / NG" clearable :disabled="reviewLocked">
-									<el-option label="OK" value="OK" />
-									<el-option label="NG" value="NG" />
-								</el-select>
-							</template>
-						</template>
-					</el-table-column>
-					<el-table-column label="单项结果" min-width="108" align="center">
-						<template #default="{ row }">
-							<el-tag :type="judgeTag(row.itemResult)" size="small" disable-transitions>{{ row.itemResult || '—' }}</el-tag>
-							<div class="item-status">{{ row.itemStatus || '' }}</div>
-						</template>
-					</el-table-column>
-				</el-table>
-
-				<div class="drawer-foot" v-if="!reviewLocked">
-					<span class="foot-hint">判定以后端为准</span>
-					<el-button :loading="saving" @click="onSave">保存</el-button>
-					<el-button type="primary" :loading="completing" @click="onComplete">完成检验</el-button>
-				</div>
-				<div class="drawer-foot" v-else>
-					<span class="foot-hint">已提交审核,检验结果只读</span>
-				</div>
-
-				<div class="review-panel" v-if="detail">
-					<div class="snap-title">审核流程 · 复用 ApprovalFlow(质量结果只读,审批不改写)</div>
-					<div class="review-row">
-						检验结果:<el-tag :type="judgeTag(detail?.overallResult)" size="small" disable-transitions>{{ review?.qualityText || detail?.overallResult || '—' }}</el-tag>
-						<span class="rv-sep">审核状态:</span><b>{{ review?.displayStatus || '—' }}</b>
-						<span v-if="review?.currentNodeName" class="rv-sep">当前节点:{{ review.currentNodeName }}</span>
-					</div>
-					<div v-if="review?.disposition" class="review-disp">
-						质量处置:<el-tag type="warning" size="small" disable-transitions>{{ review.disposition.dispositionTypeText || review.disposition.dispositionType }}</el-tag>
-						<span v-if="review.disposition.dispositionOpinion" class="rv-sep">意见:{{ review.disposition.dispositionOpinion }}</span>
-						<span v-if="review.disposition.operatorName" class="rv-sep">处置人:{{ review.disposition.operatorName }}</span>
-						<span v-if="review.disposition.submittedAt" class="rv-sep">{{ fmtDate(review.disposition.submittedAt) }}</span>
-					</div>
-					<div v-if="review?.history && review.history.length" class="review-history">
-						<div class="hist-title">审核历史</div>
-						<el-timeline>
-							<el-timeline-item v-for="(h, i) in review.history" :key="i" :timestamp="fmtDateTime(h.time)" placement="top" size="small">
-								<span class="hist-node">{{ h.nodeName }}</span> · {{ h.actionText }} <span class="hist-op">{{ h.operatorName }}</span>
-								<span v-if="h.comment" class="rv-sep">意见:{{ h.comment }}</span>
-							</el-timeline-item>
-						</el-timeline>
-					</div>
-					<div class="review-actions">
-						<el-button v-if="review?.canSubmitReview" type="primary" :loading="reviewing" @click="onSubmitReview">提交审核</el-button>
-						<el-button v-if="review?.canSupervisorApprove" type="success" :loading="reviewing" @click="onSupervisorApprove">主管通过</el-button>
-						<el-button v-if="review?.canSupervisorReturn" :loading="reviewing" @click="onSupervisorReturn">退回</el-button>
-						<template v-if="review?.canSubmitDisposition">
-							<el-select v-model="dispType" size="small" style="width: 150px" placeholder="处置类型">
-								<el-option label="返工 REWORK" value="REWORK" />
-								<el-option label="让步 CONCESSION" value="CONCESSION" />
-								<el-option label="报废 SCRAP" value="SCRAP" />
-								<el-option label="其它 OTHER" value="OTHER" />
-							</el-select>
-							<el-input v-model="dispOpinion" size="small" style="width: 220px" placeholder="处置意见(必填)" />
-							<el-button type="warning" :loading="reviewing" @click="onQeDisposition">提交处置</el-button>
-						</template>
-						<el-tag v-if="review?.isCompleted" :type="review?.flowStatus === 'Approved' ? 'success' : 'info'" size="small" disable-transitions>{{ review?.flowStatus === 'Approved' ? '审核已闭环 (CLOSED)' : review?.flowStatus }}</el-tag>
-					</div>
-				</div>
-			</div>
-		</el-drawer>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts">
+/**
+ * S6 生产指令单列表 —— 过程检验的「待检来源」。
+ *
+ * 原先这里还内嵌一个 el-drawer 承担完整的过程检验单操作(逐样本录入、保存、完成检验、
+ * 提交审核、主管通过/退回、QE 处置、审核历史)。该 Drawer 已退役:检查单现在有
+ * 独立 Tab「IPQC 检查单」与独立详情页,完整操作在那里完成。
+ * 本页回归单一职责 —— 只读的待检工单列表 + 一个进入检查单的入口。
+ *
+ * 行操作与 deep-link 的业务语义未变:已建单直接进详情;未建单先调 prepare
+ * (后端自动唯一解析 S0 检规、幂等建单 + 冻结快照),MATCHED 才跳、其余明确阻断。
+ */
 import { reactive, ref, computed } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
 import {
 	fetchProductionInstructionPage,
 	prepareProcessInspection,
-	fetchProcessInspectionDetail,
-	saveProcessInspectionResult,
-	completeProcessInspection,
-	fetchReviewState,
-	submitReview,
-	reviewSupervisorApprove,
-	reviewSupervisorReturn,
-	reviewQeDisposition,
 	type ProductionInstructionRow,
-	type ProcessInspectionDetail,
-	type ProcessInspectionSnapshotLine,
-	type SaveResultSample,
-	type ReviewState,
 } from '../api/productionInstruction';
 
+const IPQC_DETAIL_PATH = '/aidop/s6/process-quality/ipqc-bill-detail';
+
+const router = useRouter();
 const loading = ref(false);
 const rows = ref<ProductionInstructionRow[]>([]);
 const total = ref(0);
-
 const inspecting = ref<string>('');
-const detailVisible = ref(false);
-const detailLoading = ref(false);
-const detail = ref<ProcessInspectionDetail | null>(null);
-
-// Phase 1C 录入模型:key=`${snapshotLineId}#${sampleIndex}` → 实测值字符串(NUMERIC 数值串 / NON_NUMERIC OK|NG)。
-// 前端仅用于即时反馈与提交实测值,判定/整单结果一律以后端计算为准(不提交 judgement)。
-const entry = reactive<Record<string, string>>({});
-const saving = ref(false);
-const completing = ref(false);
-
-// Phase 1D 审核流状态(后端拥有;前端只触发动作、不回传状态/判定)
-const review = ref<ReviewState | null>(null);
-const reviewing = ref(false);
-const dispType = ref('');
-const dispOpinion = ref('');
-
-// 已提交审核后检验结果只读(§14 CLOSED / §15 IN_REVIEW):流程一旦发起即锁录入。
-const reviewLocked = computed(() => {
-	const fs = review.value?.flowStatus;
-	return !!fs && fs !== 'NotStarted';
-});
-
-function fmtDateTime(v?: string) {
-	if (!v) return '—';
-	return String(v).replace('T', ' ').slice(0, 19);
-}
-
-async function loadReview(billId: number) {
-	try {
-		review.value = await fetchReviewState(billId);
-	} catch {
-		review.value = null;
-	}
-}
-
-async function onSubmitReview() {
-	const billId = detail.value?.billId;
-	if (!billId || reviewing.value) return;
-	reviewing.value = true;
-	try {
-		review.value = await submitReview(billId);
-		ElMessage.success('已提交审核');
-	} catch (e: any) {
-		const msg = e?.response?.data?.message || e?.message || '';
-		ElMessage.error(String(msg).includes('INSPECTION_NOT_COMPLETED') ? '检验未完成,无法提交审核' : '提交审核失败');
-		await loadReview(billId);
-	} finally {
-		reviewing.value = false;
-	}
-}
-
-async function onSupervisorApprove() {
-	const billId = detail.value?.billId;
-	if (!billId || reviewing.value) return;
-	reviewing.value = true;
-	try {
-		review.value = await reviewSupervisorApprove(billId);
-		ElMessage.success('主管审核通过');
-	} catch {
-		ElMessage.error('主管审核失败');
-		await loadReview(billId);
-	} finally {
-		reviewing.value = false;
-	}
-}
-
-async function onSupervisorReturn() {
-	const billId = detail.value?.billId;
-	if (!billId || reviewing.value) return;
-	try {
-		const { value } = await ElMessageBox.prompt('退回意见(必填)', '退回', { inputPattern: /\S+/, inputErrorMessage: '意见必填' });
-		reviewing.value = true;
-		review.value = await reviewSupervisorReturn(billId, value);
-		ElMessage.success('已退回');
-	} catch (e) {
-		if (e !== 'cancel') ElMessage.error('退回失败');
-	} finally {
-		reviewing.value = false;
-	}
-}
-
-async function onQeDisposition() {
-	const billId = detail.value?.billId;
-	if (!billId || reviewing.value) return;
-	if (!dispType.value) return ElMessage.warning('请选择处置类型');
-	if (!dispOpinion.value?.trim()) return ElMessage.warning('处置意见必填');
-	reviewing.value = true;
-	try {
-		review.value = await reviewQeDisposition({ billId, dispositionType: dispType.value, dispositionOpinion: dispOpinion.value.trim() });
-		ElMessage.success('已提交质量处置');
-		dispType.value = '';
-		dispOpinion.value = '';
-	} catch {
-		ElMessage.error('提交处置失败');
-		await loadReview(billId);
-	} finally {
-		reviewing.value = false;
-	}
-}
 
 const query = reactive({
 	page: 1,
@@ -404,7 +197,7 @@ async function loadList() {
 		});
 		rows.value = res?.list ?? [];
 		total.value = res?.total ?? 0;
-	} catch (e) {
+	} catch {
 		rows.value = [];
 		total.value = 0;
 		ElMessage.error('生产指令单列表加载失败');
@@ -435,14 +228,22 @@ function onSizeChange() {
 
 // Phase 1B:点击 → 后端自动唯一解析检规(无人工选择)。
 // MATCHED 幂等生成/复用检验单 + 快照 → 打开录入详情;其余状态明确阻断、不建单。
+/**
+ * 进入检查单:已建单直接跳独立详情页;未建单先 prepare(后端自动唯一解析检规、幂等建单)。
+ * MATCHED 才跳转,其余状态明确阻断、不建单 —— 与退役前的 Drawer 入口行为逐条一致。
+ */
 async function onProcessInspect(row: ProductionInstructionRow) {
+	if (row.billId) {
+		router.push(`${IPQC_DETAIL_PATH}/${row.billId}`);
+		return;
+	}
 	const wo = row.workOrderNo;
 	if (!wo || inspecting.value) return;
 	inspecting.value = wo;
 	try {
 		const r = await prepareProcessInspection(wo);
 		if (r?.status === 'MATCHED' && r.billId) {
-			await openDetail(r.billId);
+			router.push(`${IPQC_DETAIL_PATH}/${r.billId}`);
 		} else if (r?.status === 'NO_MATCH') {
 			await ElMessageBox.alert(r.message || '当前物料未配置有效过程检验规范,请维护 S0 过程检验规范。', '无法生成过程检验单', { type: 'warning' });
 		} else if (r?.status === 'AMBIGUOUS') {
@@ -459,133 +260,13 @@ async function onProcessInspect(row: ProductionInstructionRow) {
 	}
 }
 
-// 录入模型 key:snapshotLineId + sampleIndex(1-based)。
-function key(lineId?: number, sampleIndex?: number) {
-	return `${lineId ?? 0}#${sampleIndex ?? 0}`;
-}
-
-// 前端即时判定(仅反馈,非真值):NUMERIC 闭区间;NON_NUMERIC OK→PASS/NG→FAIL;其余 INVALID。
-function sampleJudge(row: ProcessInspectionSnapshotLine, i: number): string {
-	const raw = (entry[key(row.id, i)] || '').trim();
-	if (!raw) return '';
-	if (row.resultType === 'NUMERIC') {
-		const n = Number(raw);
-		if (raw === '' || !Number.isFinite(n)) return 'INVALID';
-		const lo = row.lowerLimit != null && String(row.lowerLimit).trim() !== '' ? Number(row.lowerLimit) : null;
-		const up = row.upperLimit != null && String(row.upperLimit).trim() !== '' ? Number(row.upperLimit) : null;
-		if (lo === null && up === null) return 'INVALID';
-		if ((lo !== null && n < lo) || (up !== null && n > up)) return 'FAIL';
-		return 'PASS';
-	}
-	const u = raw.toUpperCase();
-	if (u === 'OK') return 'PASS';
-	if (u === 'NG') return 'FAIL';
-	return 'INVALID';
-}
-
-function judgeTag(result?: string): 'success' | 'danger' | 'info' | 'warning' {
-	if (result === 'PASS') return 'success';
-	if (result === 'FAIL') return 'danger';
-	if (result === 'INVALID') return 'warning';
-	return 'info';
-}
-
-function shortJ(result: string): string {
-	if (result === 'PASS') return '✓';
-	if (result === 'FAIL') return '✗';
-	if (result === 'INVALID') return '!';
-	return '';
-}
-
-// 由录入模型构造提交样本:仅提交非空实测值;后端按快照配置判定并计算整单结果。
-function buildSamples(): SaveResultSample[] {
-	const out: SaveResultSample[] = [];
-	for (const line of detail.value?.lines ?? []) {
-		const cnt = line.resultType === 'NUMERIC' ? line.sampleCount || 1 : 1;
-		for (let i = 1; i <= cnt; i++) {
-			const raw = (entry[key(line.id, i)] || '').trim();
-			if (!raw) continue;
-			out.push({ snapshotLineId: line.id as number, sampleIndex: i, actualValue: raw });
-		}
-	}
-	return out;
-}
-
-// 从后端已录样本初始化录入模型(刷新可恢复)。
-function initEntry() {
-	for (const k of Object.keys(entry)) delete entry[k];
-	for (const line of detail.value?.lines ?? []) {
-		for (const s of line.samples ?? []) {
-			const val = line.resultType === 'NUMERIC' ? (s.actualNumeric != null ? String(s.actualNumeric) : '') : s.actualNonNumeric || '';
-			if (val !== '' && s.sampleIndex != null) entry[key(line.id, s.sampleIndex)] = val;
-		}
-	}
-}
-
-async function openDetail(billId: number) {
-	detailVisible.value = true;
-	detailLoading.value = true;
-	detail.value = null;
-	try {
-		detail.value = await fetchProcessInspectionDetail(billId);
-		initEntry();
-		await loadReview(billId);
-	} catch {
-		ElMessage.error('检验单详情加载失败');
-	} finally {
-		detailLoading.value = false;
-	}
-}
-
-async function reloadDetail() {
-	const billId = detail.value?.billId;
-	if (!billId) return;
-	detail.value = await fetchProcessInspectionDetail(billId);
-	initEntry();
-	await loadReview(billId);
-}
-
-async function onSave() {
-	const billId = detail.value?.billId;
-	if (!billId || saving.value) return;
-	saving.value = true;
-	try {
-		await saveProcessInspectionResult(billId, buildSamples());
-		await reloadDetail();
-	} catch {
-		ElMessage.error('保存失败');
-	} finally {
-		saving.value = false;
-	}
-}
-
-async function onComplete() {
-	const billId = detail.value?.billId;
-	if (!billId || completing.value) return;
-	completing.value = true;
-	try {
-		await saveProcessInspectionResult(billId, buildSamples());
-		const r = await completeProcessInspection(billId);
-		await reloadDetail();
-		ElMessage.success(`已完成检验:整单 ${r?.overallResult || detail.value?.overallResult || '—'}`);
-	} catch (e: any) {
-		const msg = e?.response?.data?.message || e?.message || '';
-		if (String(msg).includes('INSPECTION_INCOMPLETE')) {
-			ElMessage.warning('尚有检验项未按频次录满,无法完成检验。');
-		} else {
-			ElMessage.error('完成检验失败');
-		}
-		await reloadDetail();
-	} finally {
-		completing.value = false;
-	}
-}
-
 const route = useRoute();
 loadList();
-// Deep-link(Approval Center / URL 刷新):?billId=X 直接打开对应单据详情,不依赖列表内存 state。
+
+// 兼容既有 deep-link:审批中心与旧书签仍指向 `order-list?billId=X`。
+// Drawer 已退役,这里原地重定向到独立详情页 —— 旧链接继续可用,无需改审批中心。
 const deepBillId = Number(route.query.billId);
-if (deepBillId > 0) openDetail(deepBillId);
+if (deepBillId > 0) router.replace(`${IPQC_DETAIL_PATH}/${deepBillId}`);
 </script>
 
 <style scoped>
@@ -597,71 +278,4 @@ if (deepBillId > 0) openDetail(deepBillId);
 	display: flex;
 	justify-content: flex-end;
 }
-.snap-title {
-	margin: 14px 0 8px;
-	font-weight: 600;
-	font-size: 14px;
-}
-.sample-cell {
-	display: inline-flex;
-	align-items: center;
-	gap: 3px;
-	margin: 2px 6px 2px 0;
-}
-.item-status {
-	font-size: 11px;
-	color: #909399;
-	margin-top: 2px;
-}
-.drawer-foot {
-	margin-top: 14px;
-	display: flex;
-	align-items: center;
-	justify-content: flex-end;
-	gap: 10px;
-}
-.foot-hint {
-	color: #909399;
-	font-size: 12px;
-	margin-right: auto;
-}
-.review-panel {
-	margin-top: 16px;
-	padding-top: 12px;
-	border-top: 1px solid #ebeef5;
-}
-.review-row,
-.review-disp {
-	display: flex;
-	align-items: center;
-	flex-wrap: wrap;
-	gap: 4px;
-	margin: 6px 0;
-	font-size: 13px;
-}
-.rv-sep {
-	margin-left: 12px;
-	color: #606266;
-}
-.review-actions {
-	display: flex;
-	align-items: center;
-	flex-wrap: wrap;
-	gap: 8px;
-	margin-top: 10px;
-}
-.review-history {
-	margin-top: 12px;
-}
-.hist-title {
-	font-weight: 600;
-	font-size: 13px;
-	margin-bottom: 8px;
-}
-.hist-node {
-	font-weight: 600;
-}
-.hist-op {
-	color: #409eff;
-}
 </style>

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

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

+ 89 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.543.sql

@@ -0,0 +1,89 @@
+-- =============================================================================
+-- S6-IPQC-BILL-PAGE-1 · 「IPQC 检查单」独立 Tab + 独立详情页菜单
+--
+-- 背景:S6 的过程检验单(S6_PROCESS_INSPECTION 链,源 ado_s6_process_inspection_bill)
+-- 此前没有任何菜单入口 —— 用户必须先进「生产指令单列表」,再从行操作打开一个 Drawer
+-- 才能完成录入/提交/审核/处置。本脚本为它建立独立入口,与 S7 FQC 的信息架构对齐。
+--
+-- 两条同名链务必区分(改错会白做):
+--   本脚本服务的是 S6_PROCESS_INSPECTION(工序级、有 S0 检规快照、逐样本实测);
+--   既有菜单 1329016020001「过程检验单列表」是另一条 IPQC_INSPECTION 链(源 qms_gcjyd、
+--   单头级汇总),本脚本完全不动它。
+--
+-- 路由形态沿用 S5 IQC(1.0.535.sql)的修正版,而不是 S7 FQC(1.0.382.sql):
+--   * 详情页用 path param /:id,不用 ?id= —— 路径即状态,便于直链与刷新;
+--   * 详情页 IsKeepAlive = 0。它是强状态页面(可录入 → 只读 → 退回 → 可录入),
+--     KeepAlive 会缓存旧的 canEdit / flowState / 项目结果。S7 那条设成 1,是已知问题。
+--   * 列表页 IsKeepAlive = 1,从详情返回时保留分页与查询条件(页面自身 onActivated 重新取数)。
+--
+-- 授权口径同样沿用 S5:租户可见性与角色授权都**子查询继承**同目录入口菜单
+-- 1329016020001 的既有授权面(实测 10 个租户 / 23 个角色),不硬编码 RoleId、
+-- 不新建权限码(Permission 留 NULL,与 S6 其余菜单一致)。
+-- 这样后续给该目录加/减授权时,这两个新菜单自动跟随,不会漏配。
+--
+-- Id 生成沿用 1.0.535.sql 的 `MAX(Id) + ROW_NUMBER()`,且**一段只处理一个 MenuId** ——
+-- 两个菜单不合并成一条 CROSS JOIN。合并写法需要在 Id 里同时编码「菜单序」与「租户序」,
+-- 极易写出对同一菜单所有租户取同值的表达式而撞主键(本脚本第一版就是这么挂的)。
+--
+-- 幂等:每段 INSERT 都带 NOT EXISTS;重复执行 0 变更。
+-- =============================================================================
+
+-- ── 1. 列表页菜单(可见 Tab)──
+INSERT INTO SysMenu (Id, Pid, Type, Name, Path, Component, Title, Icon, IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, Remark, CreateTime)
+SELECT 1329016020002, 1322000000020, 2, 'aidopS6IpqcBillList',
+       '/aidop/s6/process-quality/ipqc-bill-list',
+       '/aidop/s6/process-quality/ipqcBillList',
+       'IPQC 检查单', 'ele-Tickets',
+       0, 0, 1, 0, 20, 1, 'S6-IPQC-BILL-PAGE-1 过程检验单(工序级)独立入口', NOW()
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM (SELECT Id FROM SysMenu) t WHERE t.Id = 1329016020002);
+
+-- ── 2. 详情页菜单(hidden child route,不出现在菜单树里)──
+INSERT INTO SysMenu (Id, Pid, Type, Name, Path, Component, Title, Icon, IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, Remark, CreateTime)
+SELECT 1329016020003, 1322000000020, 2, 'aidopS6IpqcBillDetail',
+       '/aidop/s6/process-quality/ipqc-bill-detail/:id',
+       '/aidop/s6/process-quality/ipqcBillDetail',
+       'IPQC 检查单详情', 'ele-Document',
+       0, 1, 0, 0, 21, 1, 'S6-IPQC-BILL-PAGE-1 强状态详情页,IsKeepAlive=0', NOW()
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM (SELECT Id FROM SysMenu) t WHERE t.Id = 1329016020003);
+
+-- ── 3. 列表页租户可见性:继承 1329016020001 的租户面 ──
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.TenantId), src.TenantId, 1329016020002
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysTenantMenu) base
+CROSS JOIN (SELECT DISTINCT TenantId FROM SysTenantMenu WHERE MenuId = 1329016020001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM (SELECT TenantId, MenuId FROM SysTenantMenu) x
+    WHERE x.TenantId = src.TenantId AND x.MenuId = 1329016020002
+);
+
+-- ── 4. 详情页租户可见性:同上 ──
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.TenantId), src.TenantId, 1329016020003
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysTenantMenu) base
+CROSS JOIN (SELECT DISTINCT TenantId FROM SysTenantMenu WHERE MenuId = 1329016020001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM (SELECT TenantId, MenuId FROM SysTenantMenu) x
+    WHERE x.TenantId = src.TenantId AND x.MenuId = 1329016020003
+);
+
+-- ── 5. 列表页角色授权:继承 1329016020001 的角色面 ──
+INSERT INTO SysRoleMenu (Id, RoleId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.RoleId), src.RoleId, 1329016020002
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysRoleMenu) base
+CROSS JOIN (SELECT DISTINCT RoleId FROM SysRoleMenu WHERE MenuId = 1329016020001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM (SELECT RoleId, MenuId FROM SysRoleMenu) x
+    WHERE x.RoleId = src.RoleId AND x.MenuId = 1329016020002
+);
+
+-- ── 6. 详情页角色授权:同上 ──
+INSERT INTO SysRoleMenu (Id, RoleId, MenuId)
+SELECT base.m + ROW_NUMBER() OVER (ORDER BY src.RoleId), src.RoleId, 1329016020003
+FROM (SELECT COALESCE(MAX(Id), 0) AS m FROM SysRoleMenu) base
+CROSS JOIN (SELECT DISTINCT RoleId FROM SysRoleMenu WHERE MenuId = 1329016020001) src
+WHERE NOT EXISTS (
+    SELECT 1 FROM (SELECT RoleId, MenuId FROM SysRoleMenu) x
+    WHERE x.RoleId = src.RoleId AND x.MenuId = 1329016020003
+);

+ 47 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.543.verify.sql

@@ -0,0 +1,47 @@
+-- S6-IPQC-BILL-PAGE-1 后置校验。每条语句必须返回真值标量,否则 AutoVersionUpdate 判失败。
+
+-- 1. 列表页菜单存在且配置正确(可见 Tab、KeepAlive 保留列表状态)
+SELECT COUNT(*) = 1 FROM SysMenu
+WHERE Id = 1329016020002 AND Pid = 1322000000020 AND Type = 2 AND Status = 1
+  AND Path = '/aidop/s6/process-quality/ipqc-bill-list'
+  AND Component = '/aidop/s6/process-quality/ipqcBillList'
+  AND IsHide = 0 AND IsKeepAlive = 1;
+
+-- 2. 详情页菜单存在、隐藏、且 KeepAlive 必须为 0(强状态页面,不允许缓存旧 canEdit/flowState)
+SELECT COUNT(*) = 1 FROM SysMenu
+WHERE Id = 1329016020003 AND Pid = 1322000000020 AND Type = 2 AND Status = 1
+  AND Path = '/aidop/s6/process-quality/ipqc-bill-detail/:id'
+  AND Component = '/aidop/s6/process-quality/ipqcBillDetail'
+  AND IsHide = 1 AND IsKeepAlive = 0;
+
+-- 3. 两个新菜单的租户面与入口菜单完全一致(继承而非硬编码)
+SELECT (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329016020002)
+     = (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329016020001);
+SELECT (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329016020003)
+     = (SELECT COUNT(DISTINCT TenantId) FROM SysTenantMenu WHERE MenuId = 1329016020001);
+SELECT NOT EXISTS (
+    SELECT 1 FROM (SELECT TenantId FROM SysTenantMenu WHERE MenuId = 1329016020001) a
+    WHERE a.TenantId NOT IN (SELECT TenantId FROM SysTenantMenu WHERE MenuId = 1329016020002)
+);
+
+-- 4. 两个新菜单的角色面与入口菜单完全一致
+SELECT (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329016020002)
+     = (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329016020001);
+SELECT (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329016020003)
+     = (SELECT COUNT(DISTINCT RoleId) FROM SysRoleMenu WHERE MenuId = 1329016020001);
+SELECT NOT EXISTS (
+    SELECT 1 FROM (SELECT RoleId FROM SysRoleMenu WHERE MenuId = 1329016020001) a
+    WHERE a.RoleId NOT IN (SELECT RoleId FROM SysRoleMenu WHERE MenuId = 1329016020002)
+);
+
+-- 5. 未新建权限码(与 S6 其余菜单一致,Permission 留空)
+SELECT COUNT(*) = 0 FROM SysMenu
+WHERE Id IN (1329016020002, 1329016020003) AND Permission IS NOT NULL AND Permission <> '';
+
+-- 6. 另一条链的既有菜单 1329016020001 未被本脚本改动(标题仍是原值)
+SELECT COUNT(*) = 1 FROM SysMenu
+WHERE Id = 1329016020001 AND Title = '过程检验单列表'
+  AND Component = '/aidop/s6/process-quality/processInspectionList';
+
+-- 7. 幂等:两个新菜单各只有一行
+SELECT COUNT(*) = 2 FROM SysMenu WHERE Id IN (1329016020002, 1329016020003);