Browse Source

fix(s0): close quality form lookup gaps

S0 质量建模 15 页:关系/枚举字段不再让用户接触内部 id,列表不再出现裸 FK。

- 新增 qualityLookups.ts:统一 lookup 取数,复用后端既有 `{entity}/options`
  (均走 AdoS0TenantScope 租户作用域,前端不传 tenantId、不参与 scope 判定);
  模块级缓存保证每个 lookup 每页只取一次,列表解析 FK 名称不产生 per-row 请求
- qualityConfigs.ts:按字段语义审计结果给 19 处字段/列标注 lookup
  检验项目 检验方法/检验依据、白名单 维度类型、检验方案 业务类型、
  检验依据/标准/方案/抽样方案 组织字段、方案明细 抽样方案/检验标准/检验频率
- 两个共享 CRUD 渲染器:lookup 字段渲染为下拉,列表列解析为业务名称
- 枚举取值一律沿用数据库原始存储值(supplier/material/material_supplier、
  IQC/PQC/FQC/OQC),只做 label 可读化,不改落库语义
- buildAggregateCrudApi 补 options()(检验依据/检验标准控制器本就暴露该接口)

顺带修复既有缺陷:Number(row.id) 对雪花 id 精度溢出(...2001 被截断为 ...2000)
导致编辑/删除请求 404,编辑弹窗打不开。主键全链改为 string,新增 EntityId 类型。

chore: bump version Web 2.4.330
YY968XX 3 ngày trước cách đây
mục cha
commit
9cc314f929

+ 1 - 1
Web/package.json

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

+ 14 - 6
Web/src/views/aidop/s0/api/s0QualityApi.ts

@@ -24,13 +24,19 @@ export interface AggregateDetail<TMaster = Record<string, any>, TItem = Record<s
 
 export type GenericRecord = Record<string, any>;
 
+/**
+ * 实体主键。后端 `AddLongTypeConverters()` 把 long 序列化为 string 以防 JS 精度溢出;
+ * 雪花 id 可超过 2^53,**禁止朝 number 方向强转**(会把 ...2001 截断成 ...2000 导致 404)。
+ */
+export type EntityId = string | number;
+
 function buildSimpleCrudApi(base: string) {
 	return {
 		list: (params: GenericRecord) => service.get<Paged<GenericRecord>>(base, { params }).then(unwrap),
-		get: (id: number) => service.get<GenericRecord>(`${base}/${id}`).then(unwrap),
+		get: (id: EntityId) => service.get<GenericRecord>(`${base}/${id}`).then(unwrap),
 		create: (data: GenericRecord) => service.post<GenericRecord>(base, data).then(unwrap),
-		update: (id: number, data: GenericRecord) => service.put<GenericRecord>(`${base}/${id}`, data).then(unwrap),
-		delete: (id: number) => service.delete(`${base}/${id}`).then(unwrap),
+		update: (id: EntityId, data: GenericRecord) => service.put<GenericRecord>(`${base}/${id}`, data).then(unwrap),
+		delete: (id: EntityId) => service.delete(`${base}/${id}`).then(unwrap),
 		options: () => service.get<OptionItem[]>(`${base}/options`).then(unwrap),
 	};
 }
@@ -38,10 +44,12 @@ function buildSimpleCrudApi(base: string) {
 function buildAggregateCrudApi(base: string) {
 	return {
 		list: (params: GenericRecord) => service.get<Paged<GenericRecord>>(base, { params }).then(unwrap),
-		get: (id: number) => service.get<AggregateDetail>(`${base}/${id}`).then(unwrap),
+		get: (id: EntityId) => service.get<AggregateDetail>(`${base}/${id}`).then(unwrap),
 		create: (data: GenericRecord) => service.post<AggregateDetail>(base, data).then(unwrap),
-		update: (id: number, data: GenericRecord) => service.put<AggregateDetail>(`${base}/${id}`, data).then(unwrap),
-		delete: (id: number) => service.delete(`${base}/${id}`).then(unwrap),
+		update: (id: EntityId, data: GenericRecord) => service.put<AggregateDetail>(`${base}/${id}`, data).then(unwrap),
+		delete: (id: EntityId) => service.delete(`${base}/${id}`).then(unwrap),
+		// 聚合控制器同样暴露 GET {base}/options(检验依据 / 检验标准),供关系字段下拉复用
+		options: () => service.get<OptionItem[]>(`${base}/options`).then(unwrap),
 	};
 }
 

+ 76 - 8
Web/src/views/aidop/s0/quality/components/QualityAggregateCrudPage.vue

@@ -25,7 +25,16 @@
 		</el-form>
 
 		<el-table :data="rows" v-loading="loading" border stripe max-height="calc(100vh - 260px)">
-			<el-table-column v-for="column in config.listColumns" :key="column.prop" :prop="column.prop" :label="column.label" :width="column.width" :min-width="column.minWidth" show-overflow-tooltip />
+			<el-table-column
+				v-for="column in config.listColumns"
+				:key="column.prop"
+				:prop="column.prop"
+				:label="column.label"
+				:width="column.width"
+				:min-width="column.minWidth"
+				:formatter="column.lookup ? (row: any) => resolveLookupLabel(column.lookup!, row[column.prop]) : undefined"
+				show-overflow-tooltip
+			/>
 			<el-table-column label="操作" width="180" fixed="right" align="center">
 				<template #default="{ row }">
 					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
@@ -49,8 +58,18 @@
 		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="1200px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="master" :rules="rules" label-width="120px" class="head-grid">
 				<el-form-item v-for="field in config.headFields" :key="field.prop" :label="field.label" :prop="field.prop">
+					<el-select
+						v-if="field.lookup"
+						v-model="master[field.prop]"
+						:placeholder="`请选择${field.label}`"
+						filterable
+						clearable
+						style="width: 100%"
+					>
+						<el-option v-for="opt in lookupOptions[field.lookup] ?? []" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
 					<el-input-number
-						v-if="field.type === 'number'"
+						v-else-if="field.type === 'number'"
 						v-model="master[field.prop]"
 						controls-position="right"
 						style="width: 100%"
@@ -71,10 +90,20 @@
 
 			<el-table :data="items" border stripe class="detail-table">
 				<el-table-column type="index" label="#" width="50" />
-				<el-table-column v-for="column in config.detailColumns" :key="column.prop" :label="column.label" :width="column.type === 'number' ? 140 : undefined" :min-width="column.type === 'number' ? undefined : 150">
+				<el-table-column v-for="column in config.detailColumns" :key="column.prop" :label="column.label" :width="!column.lookup && column.type === 'number' ? 140 : undefined" :min-width="!column.lookup && column.type === 'number' ? undefined : 180">
 					<template #default="{ row }">
+						<el-select
+							v-if="column.lookup"
+							v-model="row[column.prop]"
+							:placeholder="`请选择${column.label}`"
+							filterable
+							clearable
+							style="width: 100%"
+						>
+							<el-option v-for="opt in lookupOptions[column.lookup] ?? []" :key="opt.value" :label="opt.label" :value="opt.value" />
+						</el-select>
 						<el-input-number
-							v-if="column.type === 'number'"
+							v-else-if="column.type === 'number'"
 							v-model="row[column.prop]"
 							controls-position="right"
 							style="width: 100%"
@@ -103,6 +132,7 @@ import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../../components/AidopDemoShell.vue';
 import type { QualityAggregatePageConfig } from '../qualityConfigs';
+import { invalidateQualityLookups, loadQualityLookup, type QualityLookupKey, type QualityOption } from '../qualityLookups';
 
 const props = defineProps<{ config: QualityAggregatePageConfig }>();
 
@@ -115,12 +145,46 @@ const total = ref(0);
 const loading = ref(false);
 const dialogVisible = ref(false);
 const dialogTitle = ref('');
-const editingId = ref<number | null>(null);
+// 雪花 id 超 2^53,必须按 string 保留原值,禁止 Number() 强转
+const editingId = ref<string | null>(null);
 const saving = ref(false);
 const importing = ref(false);
 const formRef = ref<FormInstance>();
 const master = reactive<Record<string, any>>({});
 const items = ref<Record<string, any>[]>([]);
+const lookupOptions = ref<Partial<Record<QualityLookupKey, QualityOption[]>>>({});
+
+/** 本页用到的全部 lookup(列表列 + 头表字段 + 明细列去重)——每个 key 只取一次,无 per-row 请求 */
+const usedLookups = computed<QualityLookupKey[]>(() => [
+	...new Set(
+		[
+			...props.config.listColumns.map((c) => c.lookup),
+			...props.config.headFields.map((f) => f.lookup),
+			...props.config.detailColumns.map((c) => c.lookup),
+		].filter(Boolean) as QualityLookupKey[]
+	),
+]);
+
+async function loadLookups() {
+	const entries = await Promise.all(usedLookups.value.map(async (k) => [k, await loadQualityLookup(k)] as const));
+	lookupOptions.value = Object.fromEntries(entries) as Partial<Record<QualityLookupKey, QualityOption[]>>;
+}
+
+/** 列表展示:把关系/枚举取值解析成业务名称;解析不到时回退原值,绝不显示 undefined */
+function resolveLookupLabel(key: QualityLookupKey, raw: unknown): string {
+	if (raw === null || raw === undefined || raw === '') return '';
+	const hit = (lookupOptions.value[key] ?? []).find((o) => o.value === String(raw));
+	return hit ? hit.label : String(raw);
+}
+
+/** lookup 字段全链按 string 比较(后端 long 序列化为 string),避免 select 反显不中 */
+function normalizeLookupValues(target: Record<string, any>, defs: Array<{ prop: string; lookup?: QualityLookupKey }>) {
+	for (const d of defs) {
+		if (d.lookup && target[d.prop] !== null && target[d.prop] !== undefined && target[d.prop] !== '') {
+			target[d.prop] = String(target[d.prop]);
+		}
+	}
+}
 
 const rules = computed<FormRules>(() =>
 	props.config.headFields.reduce<FormRules>((acc, field) => {
@@ -178,11 +242,13 @@ function openCreate() {
 
 async function openEdit(row: Record<string, any>) {
 	resetForm();
-	editingId.value = Number(row.id);
+	editingId.value = String(row.id);
 	dialogTitle.value = `编辑${props.config.entityLabel}`;
 	const detail = await props.config.api.get(editingId.value);
 	Object.assign(master, structuredClone(detail.master ?? {}));
 	items.value = structuredClone(detail.items ?? []);
+	normalizeLookupValues(master, props.config.headFields);
+	items.value.forEach((it) => normalizeLookupValues(it, props.config.detailColumns));
 	dialogVisible.value = true;
 }
 
@@ -199,7 +265,8 @@ async function submitForm() {
 			ElMessage.success('已创建');
 		}
 		dialogVisible.value = false;
-		await loadList();
+		invalidateQualityLookups();
+		await Promise.all([loadList(), loadLookups()]);
 	} finally {
 		saving.value = false;
 	}
@@ -235,7 +302,7 @@ async function onImportFileChange(uploadFile: { raw?: File }) {
 function onDelete(row: Record<string, any>) {
 	ElMessageBox.confirm(`确定删除${props.config.entityLabel}「${row[props.config.listColumns[0]?.prop] ?? row.id}」?`, '确认', { type: 'warning' })
 		.then(async () => {
-			await props.config.api.delete(Number(row.id));
+			await props.config.api.delete(String(row.id));
 			ElMessage.success('已删除');
 			await loadList();
 		})
@@ -244,6 +311,7 @@ function onDelete(row: Record<string, any>) {
 
 onMounted(() => {
 	resetForm();
+	void loadLookups();
 	void loadList();
 });
 </script>

+ 61 - 6
Web/src/views/aidop/s0/quality/components/QualitySimpleCrudPage.vue

@@ -12,7 +12,16 @@
 		</el-form>
 
 		<el-table :data="rows" v-loading="loading" border stripe max-height="calc(100vh - 260px)">
-			<el-table-column v-for="column in config.columns" :key="column.prop" :prop="column.prop" :label="column.label" :width="column.width" :min-width="column.minWidth" show-overflow-tooltip />
+			<el-table-column
+				v-for="column in config.columns"
+				:key="column.prop"
+				:prop="column.prop"
+				:label="column.label"
+				:width="column.width"
+				:min-width="column.minWidth"
+				:formatter="column.lookup ? (row: any) => resolveLookupLabel(column.lookup!, row[column.prop]) : undefined"
+				show-overflow-tooltip
+			/>
 			<el-table-column label="操作" width="160" fixed="right" align="center">
 				<template #default="{ row }">
 					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
@@ -36,8 +45,18 @@
 		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
 				<el-form-item v-for="field in config.fields" :key="field.prop" :label="field.label" :prop="field.prop">
+					<el-select
+						v-if="field.lookup"
+						v-model="form[field.prop]"
+						:placeholder="`请选择${field.label}`"
+						filterable
+						clearable
+						style="width: 100%"
+					>
+						<el-option v-for="opt in lookupOptions[field.lookup] ?? []" :key="opt.value" :label="opt.label" :value="opt.value" />
+					</el-select>
 					<el-input-number
-						v-if="field.type === 'number'"
+						v-else-if="field.type === 'number'"
 						v-model="form[field.prop]"
 						controls-position="right"
 						style="width: 100%"
@@ -64,6 +83,7 @@ import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../../components/AidopDemoShell.vue';
 import type { QualitySimplePageConfig } from '../qualityConfigs';
+import { invalidateQualityLookups, loadQualityLookup, type QualityLookupKey, type QualityOption } from '../qualityLookups';
 
 const props = defineProps<{ config: QualitySimplePageConfig }>();
 
@@ -76,10 +96,42 @@ const total = ref(0);
 const loading = ref(false);
 const dialogVisible = ref(false);
 const dialogTitle = ref('');
-const editingId = ref<number | null>(null);
+// 雪花 id 超 2^53,必须按 string 保留原值,禁止 Number() 强转
+const editingId = ref<string | null>(null);
 const saving = ref(false);
 const formRef = ref<FormInstance>();
 const form = reactive<Record<string, any>>({});
+const lookupOptions = ref<Partial<Record<QualityLookupKey, QualityOption[]>>>({});
+
+/** 本页用到的全部 lookup(列 + 表单去重)——每个 key 只取一次,列表渲染不产生 per-row 请求 */
+const usedLookups = computed<QualityLookupKey[]>(() => [
+	...new Set(
+		[...props.config.columns.map((c) => c.lookup), ...props.config.fields.map((f) => f.lookup)].filter(
+			Boolean
+		) as QualityLookupKey[]
+	),
+]);
+
+async function loadLookups() {
+	const entries = await Promise.all(usedLookups.value.map(async (k) => [k, await loadQualityLookup(k)] as const));
+	lookupOptions.value = Object.fromEntries(entries) as Partial<Record<QualityLookupKey, QualityOption[]>>;
+}
+
+/** 列表展示:把关系/枚举取值解析成业务名称;解析不到时回退原值,绝不显示 undefined */
+function resolveLookupLabel(key: QualityLookupKey, raw: unknown): string {
+	if (raw === null || raw === undefined || raw === '') return '';
+	const hit = (lookupOptions.value[key] ?? []).find((o) => o.value === String(raw));
+	return hit ? hit.label : String(raw);
+}
+
+/** lookup 字段全链按 string 比较(后端 long 序列化为 string),避免 select 反显不中 */
+function normalizeLookupValues(target: Record<string, any>) {
+	for (const f of props.config.fields) {
+		if (f.lookup && target[f.prop] !== null && target[f.prop] !== undefined && target[f.prop] !== '') {
+			target[f.prop] = String(target[f.prop]);
+		}
+	}
+}
 
 const rules = computed<FormRules>(() =>
 	props.config.fields.reduce<FormRules>((acc, field) => {
@@ -127,10 +179,11 @@ function openCreate() {
 
 async function openEdit(row: Record<string, any>) {
 	resetForm();
-	editingId.value = Number(row.id);
+	editingId.value = String(row.id);
 	dialogTitle.value = `编辑${props.config.entityLabel}`;
 	const detail = await props.config.api.get(editingId.value);
 	Object.assign(form, structuredClone(detail));
+	normalizeLookupValues(form);
 	dialogVisible.value = true;
 }
 
@@ -147,7 +200,8 @@ async function submitForm() {
 			ElMessage.success('已创建');
 		}
 		dialogVisible.value = false;
-		await loadList();
+		invalidateQualityLookups();
+		await Promise.all([loadList(), loadLookups()]);
 	} finally {
 		saving.value = false;
 	}
@@ -156,7 +210,7 @@ async function submitForm() {
 function onDelete(row: Record<string, any>) {
 	ElMessageBox.confirm(`确定删除${props.config.entityLabel}「${row[props.config.columns[0]?.prop] ?? row.id}」?`, '确认', { type: 'warning' })
 		.then(async () => {
-			await props.config.api.delete(Number(row.id));
+			await props.config.api.delete(String(row.id));
 			ElMessage.success('已删除');
 			await loadList();
 		})
@@ -165,6 +219,7 @@ function onDelete(row: Record<string, any>) {
 
 onMounted(() => {
 	resetForm();
+	void loadLookups();
 	void loadList();
 });
 </script>

+ 24 - 19
Web/src/views/aidop/s0/quality/qualityConfigs.ts

@@ -20,12 +20,15 @@ import {
 	type GenericRecord,
 	type SpecImportApi,
 } from '../api/s0QualityApi';
+import type { QualityLookupKey } from './qualityLookups';
 
 export interface QualityColumnDef {
 	prop: string;
 	label: string;
 	width?: number;
 	minWidth?: number;
+	/** 该列存的是关系/枚举取值时,按此 lookup 解析成业务名称展示,避免列表出现裸 FK id */
+	lookup?: QualityLookupKey;
 }
 
 export interface QualityFieldDef {
@@ -33,6 +36,8 @@ export interface QualityFieldDef {
 	label: string;
 	type?: 'input' | 'textarea' | 'number';
 	required?: boolean;
+	/** 该字段存的是关系/枚举取值时,表单渲染为下拉,禁止用户手输内部 id */
+	lookup?: QualityLookupKey;
 }
 
 export interface QualitySimplePageConfig {
@@ -100,14 +105,14 @@ export const rawWhitelistConfig: QualitySimplePageConfig = {
 	queryPlaceholder: '供应商/物料',
 	api: s0RawWhitelistsApi,
 	columns: [
-		{ prop: 'dimensionType', label: '维度类型', width: 140 },
+		{ prop: 'dimensionType', label: '维度类型', width: 160, lookup: 'dimensionType' },
 		{ prop: 'supplierCode', label: '供应商编码', width: 220 },
 		{ prop: 'supplierName', label: '供应商名称', minWidth: 260 },
 		{ prop: 'materialCode', label: '物料编码', width: 180 },
 		{ prop: 'materialName', label: '物料名称', minWidth: 220 },
 	],
 	fields: [
-		{ prop: 'dimensionType', label: '维度类型(supplier/material/material_supplier)', required: true },
+		{ prop: 'dimensionType', label: '维度类型', required: true, lookup: 'dimensionType' },
 		{ prop: 'supplierCode', label: '供应商编码' },
 		{ prop: 'supplierName', label: '供应商名称' },
 		{ prop: 'materialCode', label: '物料编码' },
@@ -138,7 +143,7 @@ export const samplingSchemeConfig: QualitySimplePageConfig = {
 		{ prop: 'strictness', label: '严格程度' },
 		{ prop: 'aqlValue', label: 'AQL值' },
 		{ prop: 'inspectionType', label: '检验类型' },
-		{ prop: 'inspectOrgId', label: '检验组织', type: 'number' },
+		{ prop: 'inspectOrgId', label: '检验组织', lookup: 'org' },
 		{ prop: 'inspectUserId', label: '检验员' },
 		{ prop: 'status', label: '状态' },
 		{ prop: 'enableStatus', label: '启用' },
@@ -209,15 +214,15 @@ export const inspectionItemConfig: QualitySimplePageConfig = {
 	columns: [
 		{ prop: 'number', label: '编号', width: 180 },
 		{ prop: 'name', label: '名称', minWidth: 220 },
-		{ prop: 'checkMethodId', label: '检验方法', width: 120 },
-		{ prop: 'checkBasisId', label: '检验依据', width: 120 },
+		{ prop: 'checkMethodId', label: '检验方法', width: 180, lookup: 'method' },
+		{ prop: 'checkBasisId', label: '检验依据', width: 180, lookup: 'basis' },
 		{ prop: 'checkInstructId', label: '检验指导书', width: 140 },
 	],
 	fields: [
 		{ prop: 'number', label: '编号', required: true },
 		{ prop: 'name', label: '名称', required: true },
-		{ prop: 'checkMethodId', label: '检验方法', type: 'number' },
-		{ prop: 'checkBasisId', label: '检验依据', type: 'number' },
+		{ prop: 'checkMethodId', label: '检验方法', lookup: 'method' },
+		{ prop: 'checkBasisId', label: '检验依据', lookup: 'basis' },
 		{ prop: 'checkInstructId', label: '检验指导书', type: 'number' },
 		{ prop: 'radioGroupField', label: '单选组1' },
 		{ prop: 'radioGroupField1', label: '单选组2' },
@@ -263,8 +268,8 @@ export const inspectionBasisConfig: QualityAggregatePageConfig = {
 		{ prop: 'number', label: '编号', required: true },
 		{ prop: 'name', label: '名称', required: true },
 		{ prop: 'controlStrategy', label: '控制策略' },
-		{ prop: 'createOrgId', label: '创建组织', type: 'number' },
-		{ prop: 'useOrgId', label: '使用组织', type: 'number' },
+		{ prop: 'createOrgId', label: '创建组织', lookup: 'org' },
+		{ prop: 'useOrgId', label: '使用组织', lookup: 'org' },
 		{ prop: 'status', label: '状态' },
 		{ prop: 'enableStatus', label: '启用' },
 		{ prop: 'comment', label: '备注', type: 'textarea' },
@@ -293,8 +298,8 @@ export const inspectionStandardConfig: QualityAggregatePageConfig = {
 		{ prop: 'number', label: '编号', required: true },
 		{ prop: 'name', label: '名称', required: true },
 		{ prop: 'controlStrategy', label: '控制策略' },
-		{ prop: 'createOrgId', label: '创建组织', type: 'number' },
-		{ prop: 'useOrgId', label: '使用组织', type: 'number' },
+		{ prop: 'createOrgId', label: '创建组织', lookup: 'org' },
+		{ prop: 'useOrgId', label: '使用组织', lookup: 'org' },
 		{ prop: 'status', label: '状态' },
 		{ prop: 'enableStatus', label: '启用' },
 		{ prop: 'comment', label: '备注', type: 'textarea' },
@@ -307,7 +312,7 @@ export const inspectionStandardConfig: QualityAggregatePageConfig = {
 		{ prop: 'specValue', label: '标准' },
 		{ prop: 'topValue', label: '上限', type: 'number' },
 		{ prop: 'downValue', label: '下限', type: 'number' },
-		{ prop: 'checkBasisId', label: '检验依据', type: 'number' },
+		{ prop: 'checkBasisId', label: '检验依据', lookup: 'basis' },
 	],
 	initialMaster: { number: '', name: '', controlStrategy: '', createOrgId: undefined, useOrgId: undefined, status: '', enableStatus: '', comment: '' },
 	createEmptyItem: () => ({ seq: undefined, checkItems: '', checkContent: '', normType: '', specValue: '', topValue: undefined, downValue: undefined, checkBasisId: undefined, checkMethodId: undefined, checkFrequencyId: undefined, checkInstructId: undefined, unit: '', keyQuality: undefined }),
@@ -321,16 +326,16 @@ export const inspectionPlanConfig: QualityAggregatePageConfig = {
 	listColumns: [
 		{ prop: 'number', label: '编号', width: 180 },
 		{ prop: 'name', label: '名称', minWidth: 220 },
-		{ prop: 'bizTypeId', label: '业务类型', width: 160 },
+		{ prop: 'bizTypeId', label: '业务类型', width: 160, lookup: 'bizType' },
 		{ prop: 'comment', label: '备注', minWidth: 220 },
 	],
 	headFields: [
 		{ prop: 'number', label: '编号', required: true },
 		{ prop: 'name', label: '名称', required: true },
-		{ prop: 'bizTypeId', label: '检验业务类型' },
+		{ prop: 'bizTypeId', label: '检验业务类型', lookup: 'bizType' },
 		{ prop: 'controlStrategy', label: '控制策略' },
-		{ prop: 'createOrgId', label: '创建组织', type: 'number' },
-		{ prop: 'useOrgId', label: '使用组织', type: 'number' },
+		{ prop: 'createOrgId', label: '创建组织', lookup: 'org' },
+		{ prop: 'useOrgId', label: '使用组织', lookup: 'org' },
 		{ prop: 'status', label: '状态' },
 		{ prop: 'enableStatus', label: '启用' },
 		{ prop: 'comment', label: '备注', type: 'textarea' },
@@ -341,9 +346,9 @@ export const inspectionPlanConfig: QualityAggregatePageConfig = {
 		{ prop: 'materialCode', label: '物料编码' },
 		{ prop: 'materialName', label: '物料名称' },
 		{ prop: 'supplierId', label: '供应商' },
-		{ prop: 'samplingSchemeId', label: '抽样方案', type: 'number' },
-		{ prop: 'inspectionStandardId', label: '检验标准', type: 'number' },
-		{ prop: 'inspectionFrequencyId', label: '检验频率', type: 'number' },
+		{ prop: 'samplingSchemeId', label: '抽样方案', lookup: 'samplingScheme' },
+		{ prop: 'inspectionStandardId', label: '检验标准', lookup: 'standard' },
+		{ prop: 'inspectionFrequencyId', label: '检验频率', lookup: 'frequency' },
 	],
 	initialMaster: { number: '', name: '', bizTypeId: '', controlStrategy: '', createOrgId: undefined, useOrgId: undefined, status: '', enableStatus: '', comment: '' },
 	createEmptyItem: () => ({ seq: undefined, setupType: '', materialCode: '', materialName: '', materialTypeId: undefined, supplierId: '', samplingSchemeId: undefined, inspectionStandardId: undefined, inspectOrgId: undefined, inspectUserId: undefined, qRouteId: undefined, operationNo: '', operationId: undefined, inspectionFrequencyId: undefined, processSeq: '', inspectionType: undefined }),

+ 90 - 0
Web/src/views/aidop/s0/quality/qualityLookups.ts

@@ -0,0 +1,90 @@
+/**
+ * S0 质量建模 —— 关系字段 / 枚举字段的统一取数源。
+ *
+ * 设计约束:
+ * 1. 复用后端既有的 `GET {entity}/options` 接口(`AdoS0QualitySimpleOptionDto`),
+ *    这些接口本身已走 `AdoS0TenantScope.TryResolveRequired` 租户作用域,
+ *    前端不传 tenantId、不参与 scope 判定。
+ * 2. 每个 lookup 全页面只取一次(模块级 Promise 缓存),
+ *    列表渲染 FK 名称时按 id 在内存 map 里查,**不产生每行一次请求**。
+ * 3. 枚举取值一律使用数据库中的**原始存储值**,只在 label 上做中文可读化,
+ *    不改变落库语义。
+ * 4. 后端 `AddLongTypeConverters()` 把 long 序列化成 string(防 JS 精度溢出),
+ *    因此 option value 与表单值全链统一按 **string** 归一化比较。
+ */
+import { loadOrgList } from '../api/s0SalesApi';
+import {
+	s0InspectionBasesApi,
+	s0InspectionFrequenciesApi,
+	s0InspectionMethodsApi,
+	s0InspectionStandardsApi,
+	s0SamplingSchemesApi,
+} from '../api/s0QualityApi';
+
+export interface QualityOption {
+	value: string;
+	label: string;
+}
+
+export type QualityLookupKey =
+	| 'method'
+	| 'basis'
+	| 'standard'
+	| 'frequency'
+	| 'samplingScheme'
+	| 'org'
+	| 'dimensionType'
+	| 'bizType';
+
+/** 维度类型:取值域由 qms_lymjbmd.dim_type 实测确定(supplier / material / material_supplier)。 */
+const DIMENSION_TYPE_OPTIONS: QualityOption[] = [
+	{ value: 'supplier', label: '供应商(supplier)' },
+	{ value: 'material', label: '物料(material)' },
+	{ value: 'material_supplier', label: '物料+供应商(material_supplier)' },
+];
+
+/** 检验业务类型:取值域由 qms_inspectpro.FBIZSTYPEID 实测确定(IQC / PQC / FQC / OQC)。 */
+const BIZ_TYPE_OPTIONS: QualityOption[] = [
+	{ value: 'IQC', label: 'IQC 来料检验' },
+	{ value: 'PQC', label: 'PQC 过程检验' },
+	{ value: 'FQC', label: 'FQC 成品检验' },
+	{ value: 'OQC', label: 'OQC 出货检验' },
+];
+
+function normalize(list: Array<{ value: unknown; label: unknown }>): QualityOption[] {
+	return (list ?? [])
+		.filter((x) => x?.value !== undefined && x?.value !== null && String(x.value) !== '')
+		.map((x) => ({ value: String(x.value), label: String(x.label ?? '') || String(x.value) }));
+}
+
+const loaders: Record<QualityLookupKey, () => Promise<QualityOption[]>> = {
+	method: async () => normalize(await s0InspectionMethodsApi.options()),
+	basis: async () => normalize(await s0InspectionBasesApi.options()),
+	standard: async () => normalize(await s0InspectionStandardsApi.options()),
+	frequency: async () => normalize(await s0InspectionFrequenciesApi.options()),
+	samplingScheme: async () => normalize(await s0SamplingSchemesApi.options()),
+	// 组织沿用 S0 既有租户组织契约(loadOrgList 由后端按租户过滤),不新建组织体系。
+	org: async () =>
+		(await loadOrgList())
+			.filter((o) => o.id && o.id !== '0')
+			.map((o) => ({ value: String(o.id), label: o.name || o.code || String(o.id) })),
+	dimensionType: async () => DIMENSION_TYPE_OPTIONS,
+	bizType: async () => BIZ_TYPE_OPTIONS,
+};
+
+const cache = new Map<QualityLookupKey, Promise<QualityOption[]>>();
+
+/** 取 lookup 选项;同一 key 在页面生命周期内只请求一次。 */
+export function loadQualityLookup(key: QualityLookupKey): Promise<QualityOption[]> {
+	let hit = cache.get(key);
+	if (!hit) {
+		hit = loaders[key]().catch(() => [] as QualityOption[]);
+		cache.set(key, hit);
+	}
+	return hit;
+}
+
+/** 新增/编辑成功后调用,避免下拉停留在旧数据上。 */
+export function invalidateQualityLookups(): void {
+	cache.clear();
+}