Przeglądaj źródła

feat(s8): global default read-only + factory override, add S9 KPI target config | server 1.0.400 / Web 2.4.327

S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1

统一「平台默认(tenant_id=0/factory_id=0)租户侧只读 + 当前工厂覆盖」语义,
并在该语义之上落地「S9 KPI 目标值配置」。生效读取:工厂覆盖 > 平台默认。

问题背景:
P0 批次把配置对象的 Update/Delete 按可信作用域绑行后,平台默认行不在任何租户
作用域内,但异常类型 / 大屏卡片两页仍把 global 行渲染成普通「编辑 / 删除 / 启用」,
业务人员保存必然 404。本批关闭该错误交互,而不是放宽后端边界。

- 新增 S8ConfigScope:平台默认 / 工厂覆盖的单一语义来源。
- 三个实体新增不落库计算属性 Scope([SugarColumn(IsIgnore=true)]),
  异常类型 / 大屏卡片列表另附 HasGlobalDefault,用于区分
  「平台默认的工厂覆盖」(可恢复默认)与「本工厂自建」(可删除);
  树端点 ToTreeNode 同步下发 Scope,避免树 / 列表两套口径不一致。
- 异常类型 / 大屏卡片新增 {id}/factory-override:
  POST 自定义本工厂(复制平台默认 + 服务端盖章归属,平台默认行不变,
  同业务键已存在覆盖时幂等返回);DELETE 恢复平台默认(只删覆盖行)。
- 前端:平台默认行不再渲染编辑 / 删除 / 启用开关,只提供「自定义本工厂」;
  分组父节点不提供任何租户侧写动作(后端拒绝保留为兜底);
  新增「配置来源」列与只读 SLA 时限列,业务人员无需打开弹窗即可看到 SLA。
- 新增「S9 KPI 目标值配置」:复用 ado_s8_monitor_metric.default_target_ratio,
  不新建配置表;只维护 5 项 Result KPI 的目标值,按 metricCode 定位、
  不暴露 TenantId / FactoryId / DB Id;后端强校验 0–100。
- 结果 KPI 目标值改为以 DB 为唯一事实源:TargetRatio 从 _demoBaselineMap 与
  内联 90.0m 解耦,改取生效行 default_target_ratio,未配置返回 null 不做常量兜底。
  KPI 当前值 92/88/96/90 与 enabled 均不在本批范围,保持原样。
- 1.0.399.sql 初始化 5 条平台默认目标(95/95/98/95/90,带 IS NULL 幂等守卫);
  1.0.400.sql 为存量库补配置菜单(NOT EXISTS 幂等)。两脚本纯数据,无 DDL。

Runtime(UATAdminA / tenant 838257186181189)实测:
KPI / 异常类型 / 大屏卡片三条链路的「自定义本工厂 → 编辑 → 恢复平台默认」全通;
有效列表按业务键去重不重复;伪造 tenantId/factoryId/scope 与越权 Id 一律被忽略或 404;
两张 global 表校验和与操作前完全一致;SCHEMA DIFF = 0,计算属性未生成 DB 列。
YY968XX 2 dni temu
rodzic
commit
13ae0450b9
24 zmienionych plików z 1126 dodań i 20 usunięć
  1. 1 1
      Web/package.json
  2. 26 0
      Web/src/views/aidop/s8/api/s8ConfigApi.ts
  3. 60 0
      Web/src/views/aidop/s8/api/s8KpiTargetApi.ts
  4. 66 4
      Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue
  5. 2 0
      Web/src/views/aidop/s8/config/S8ConfigHubPage.vue
  6. 1 0
      Web/src/views/aidop/s8/config/S8DashboardCellConfigPage.vue
  7. 80 3
      Web/src/views/aidop/s8/config/S8ExceptionTypeConfigPage.vue
  8. 179 0
      Web/src/views/aidop/s8/config/S8KpiTargetConfigPage.vue
  9. 9 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  10. 66 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.399.sql
  11. 53 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.400.sql
  12. 25 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs
  13. 25 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs
  14. 63 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigKpiTargetsController.cs
  15. 43 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs
  16. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DashboardCellConfig.cs
  17. 17 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionType.cs
  18. 8 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8MonitorMetric.cs
  19. 39 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8ConfigScope.cs
  20. 2 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs
  21. 70 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellConfigService.cs
  22. 79 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionTypeService.cs
  23. 172 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8KpiTargetConfigService.cs
  24. 23 7
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

+ 1 - 1
Web/package.json

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

+ 26 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -46,6 +46,12 @@ export interface S8ExceptionTypeConfigRow {
 	// TASK-015-TREE-DEV-1:2 层树字段。父节点 isGroup=true 仅作为分组容器;子节点 parentId 指向父节点 id。
 	// TASK-015-TREE-DEV-1:2 层树字段。父节点 isGroup=true 仅作为分组容器;子节点 parentId 指向父节点 id。
 	parentId?: number | null;
 	parentId?: number | null;
 	isGroup?: boolean;
 	isGroup?: boolean;
+	// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域(后端计算,不落库)。
+	// scope=GLOBAL 平台默认(租户侧只读);scope=FACTORY 当前工厂行。
+	// hasGlobalDefault=true 表示该业务键存在平台默认 → 工厂行可「恢复平台默认」;
+	// false 表示本工厂自建 → 可「删除」。
+	scope?: string;
+	hasGlobalDefault?: boolean;
 }
 }
 
 
 export type S8ExceptionTypeConfigPayload =
 export type S8ExceptionTypeConfigPayload =
@@ -80,6 +86,9 @@ export interface S8DashboardCellConfigRow {
 	sortNo: number;
 	sortNo: number;
 	createdAt: string;
 	createdAt: string;
 	updatedAt?: string | null;
 	updatedAt?: string | null;
+	// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域与平台默认标记(后端计算,不落库)。
+	scope?: string;
+	hasGlobalDefault?: boolean;
 }
 }
 
 
 export type S8DashboardCellConfigPayload =
 export type S8DashboardCellConfigPayload =
@@ -229,6 +238,12 @@ export const s8ConfigApi = {
 	create: (endpoint: string, body: Record<string, unknown>) => service.post(endpoint, withAidopTenantFactoryParams(body)).then(unwrap),
 	create: (endpoint: string, body: Record<string, unknown>) => service.post(endpoint, withAidopTenantFactoryParams(body)).then(unwrap),
 	update: (endpoint: string, id: number, body: Record<string, unknown>) => service.put(`${endpoint}/${id}`, withAidopTenantFactoryParams(body)).then(unwrap),
 	update: (endpoint: string, id: number, body: Record<string, unknown>) => service.put(`${endpoint}/${id}`, withAidopTenantFactoryParams(body)).then(unwrap),
 	remove: (endpoint: string, id: number) => service.delete(`${endpoint}/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	remove: (endpoint: string, id: number) => service.delete(`${endpoint}/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+	// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:通用「自定义本工厂 / 恢复平台默认」。
+	// 归属由服务端从登录身份盖章,故不附加 tenantId / factoryId。
+	createFactoryOverride: (endpoint: string, globalId: number) =>
+		service.post(`${endpoint}/${globalId}/factory-override`).then(unwrap),
+	resetToGlobalDefault: (endpoint: string, overrideId: number) =>
+		service.delete(`${endpoint}/${overrideId}/factory-override`).then(unwrap),
 	test: (endpoint: string, id: number) => service.post(`${endpoint}/${id}/test`, null, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	test: (endpoint: string, id: number) => service.post(`${endpoint}/${id}/test`, null, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 	scenes: (params?: { tenantId?: number; factoryId?: number }) =>
 	scenes: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.get('/api/aidop/s8/config/scenes', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
 		service.get('/api/aidop/s8/config/scenes', { params: withAidopTenantFactoryParams(params ?? {}) }).then(unwrap),
@@ -271,6 +286,12 @@ export const s8ConfigApi = {
 			service.put(`/api/aidop/s8/config/exception-types/${id}/enabled`, null, { params: withAidopTenantFactoryParams({ enabled }) }).then(unwrap),
 			service.put(`/api/aidop/s8/config/exception-types/${id}/enabled`, null, { params: withAidopTenantFactoryParams({ enabled }) }).then(unwrap),
 		remove: (id: number) =>
 		remove: (id: number) =>
 			service.delete(`/api/aidop/s8/config/exception-types/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 			service.delete(`/api/aidop/s8/config/exception-types/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂 / 恢复平台默认。
+		// 归属由服务端盖章,故不附加 tenantId / factoryId。
+		createFactoryOverride: (globalId: number) =>
+			service.post<S8ExceptionTypeConfigRow>(`/api/aidop/s8/config/exception-types/${globalId}/factory-override`).then(unwrap),
+		resetToGlobalDefault: (overrideId: number) =>
+			service.delete(`/api/aidop/s8/config/exception-types/${overrideId}/factory-override`).then(unwrap),
 	},
 	},
 	watchRules: {
 	watchRules: {
 		list: (params?: { tenantId?: number; factoryId?: number }) =>
 		list: (params?: { tenantId?: number; factoryId?: number }) =>
@@ -298,6 +319,11 @@ export const s8ConfigApi = {
 			service.put<S8DashboardCellConfigRow>(`/api/aidop/s8/config/dashboard-cells/${id}`, withAidopTenantFactoryParams(body)).then(unwrap),
 			service.put<S8DashboardCellConfigRow>(`/api/aidop/s8/config/dashboard-cells/${id}`, withAidopTenantFactoryParams(body)).then(unwrap),
 		remove: (id: number) =>
 		remove: (id: number) =>
 			service.delete(`/api/aidop/s8/config/dashboard-cells/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
 			service.delete(`/api/aidop/s8/config/dashboard-cells/${id}`, { params: withAidopTenantFactoryParams({}) }).then(unwrap),
+		// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂 / 恢复平台默认。
+		createFactoryOverride: (globalId: number) =>
+			service.post<S8DashboardCellConfigRow>(`/api/aidop/s8/config/dashboard-cells/${globalId}/factory-override`).then(unwrap),
+		resetToGlobalDefault: (overrideId: number) =>
+			service.delete(`/api/aidop/s8/config/dashboard-cells/${overrideId}/factory-override`).then(unwrap),
 	},
 	},
 	notificationLayers: {
 	notificationLayers: {
 		list: (params?: { tenantId?: number; factoryId?: number }) =>
 		list: (params?: { tenantId?: number; factoryId?: number }) =>

+ 60 - 0
Web/src/views/aidop/s8/api/s8KpiTargetApi.ts

@@ -0,0 +1,60 @@
+// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:S9 KPI 目标值配置 API。
+// 只维护 Result KPI 的目标值;归属(租户 / 工厂)一律由服务端从登录身份解析并盖章,
+// 因此本模块**不附加** tenantId / factoryId 参数,也不接受调用方传入。
+import service from '/@/utils/request';
+
+function unwrap<T>(res: { data: T }): T {
+	return res.data;
+}
+
+/** S9 KPI 目标值配置行(业务视图,不含 KPI 当前值 / enabled / 归属技术字段)。 */
+export interface S8KpiTargetItem {
+	/** 指标编码:业务键,仅用于接口定位,页面不展示。 */
+	metricCode: string;
+	/** 指标名称。 */
+	metricName: string;
+	/** 单位,通常为 %。 */
+	unit: string;
+	/** 生效目标值:工厂覆盖优先,无覆盖则平台默认;均未配置时为 null(页面显示「待配置」)。 */
+	targetRatio: number | null;
+	/** 平台默认目标值,供弹窗只读展示与「恢复平台默认」预览。 */
+	globalTargetRatio: number | null;
+	/** 说明。 */
+	remark: string | null;
+	/** 作用域标识:GLOBAL / FACTORY。页面只用它推导业务文案,不直接展示。 */
+	scope: string;
+	/** 当前工厂是否已有覆盖配置。 */
+	hasFactoryOverride: boolean;
+}
+
+export interface S8KpiTargetUpdateBody {
+	targetRatio: number;
+	remark: string | null;
+}
+
+/** 列表:5 项 Result KPI 的生效目标值。 */
+export function getS8KpiTargets(): Promise<S8KpiTargetItem[]> {
+	return service.get<S8KpiTargetItem[]>('/api/aidop/s8/config/kpi-targets').then(unwrap);
+}
+
+/** 自定义本工厂 / 编辑本工厂目标值(同一入口,服务端 upsert)。 */
+export function upsertS8KpiTargetOverride(
+	metricCode: string,
+	body: S8KpiTargetUpdateBody,
+): Promise<S8KpiTargetItem> {
+	return service
+		.put<S8KpiTargetItem>(
+			`/api/aidop/s8/config/kpi-targets/${encodeURIComponent(metricCode)}/factory-override`,
+			body,
+		)
+		.then(unwrap);
+}
+
+/** 恢复平台默认:只删除当前工厂覆盖行,平台默认行不受影响。 */
+export function resetS8KpiTargetToGlobal(metricCode: string): Promise<S8KpiTargetItem> {
+	return service
+		.delete<S8KpiTargetItem>(
+			`/api/aidop/s8/config/kpi-targets/${encodeURIComponent(metricCode)}/factory-override`,
+		)
+		.then(unwrap);
+}

+ 66 - 4
Web/src/views/aidop/s8/components/config/S8CrudConfigPage.vue

@@ -44,6 +44,12 @@ const props = defineProps<{
 	validateFormExtra?: (form: Record<string, unknown>) => string | null;
 	validateFormExtra?: (form: Record<string, unknown>) => string | null;
 	/** 列表展示前的稳定排序钩子(不影响保存 payload;返回新数组)。 */
 	/** 列表展示前的稳定排序钩子(不影响保存 payload;返回新数组)。 */
 	sortRows?: (rows: readonly Record<string, unknown>[]) => Record<string, unknown>[];
 	sortRows?: (rows: readonly Record<string, unknown>[]) => Record<string, unknown>[];
+	/**
+	 * S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:该 endpoint 是否提供
+	 * `{id}/factory-override` 端点。为 true 时平台默认行显示「自定义本工厂」。
+	 * 未开启时平台默认行仍为只读(不给最终只会 404 的编辑 / 删除),只是不提供覆盖入口。
+	 */
+	supportsFactoryOverride?: boolean;
 }>();
 }>();
 
 
 const loading = ref(false);
 const loading = ref(false);
@@ -56,6 +62,40 @@ const form = reactive<Record<string, unknown>>(props.buildDefault());
 
 
 const idKey = computed(() => props.idKey ?? 'id');
 const idKey = computed(() => props.idKey ?? 'id');
 
 
+// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:平台默认 / 当前工厂覆盖语义。
+// 后端未返回 scope 的 endpoint(其列表本就只含本工厂行)行为完全不变。
+function isGlobalRow(row: Record<string, unknown>): boolean {
+	return row.scope === 'GLOBAL';
+}
+function isFactoryOverrideRow(row: Record<string, unknown>): boolean {
+	return row.scope === 'FACTORY' && row.hasGlobalDefault === true;
+}
+function sourceText(row: Record<string, unknown>): string {
+	if (!row.scope) return '';
+	return isGlobalRow(row) ? '平台默认' : '当前工厂覆盖';
+}
+
+async function createOverride(row: Record<string, unknown>) {
+	try {
+		await s8ConfigApi.createFactoryOverride(props.endpoint, Number(row[idKey.value]));
+		ElMessage.success('已创建当前工厂配置,可继续编辑');
+		await loadData();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '自定义本工厂失败');
+	}
+}
+
+async function resetToGlobal(row: Record<string, unknown>) {
+	await ElMessageBox.confirm('确认恢复平台默认配置?当前工厂的自定义配置将被移除。', '恢复平台默认', { type: 'warning' });
+	try {
+		await s8ConfigApi.resetToGlobalDefault(props.endpoint, Number(row[idKey.value]));
+		ElMessage.success('已恢复平台默认');
+		await loadData();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '恢复平台默认失败');
+	}
+}
+
 const displayRows = computed(() => (props.sortRows ? props.sortRows(rows.value) : rows.value));
 const displayRows = computed(() => (props.sortRows ? props.sortRows(rows.value) : rows.value));
 
 
 function resetForm() {
 function resetForm() {
@@ -170,11 +210,28 @@ onMounted(() => {
 					<span v-else>{{ row[column.key] }}</span>
 					<span v-else>{{ row[column.key] }}</span>
 				</template>
 				</template>
 			</el-table-column>
 			</el-table-column>
-			<el-table-column label="操作" width="220" fixed="right">
+			<el-table-column v-if="displayRows.some((r) => !!r.scope)" label="配置来源" width="120">
 				<template #default="{ row }">
 				<template #default="{ row }">
-					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
-					<el-button v-if="enableTest" link type="warning" @click="testRow(row)">测试</el-button>
-					<el-button link type="danger" @click="removeRow(row)">删除</el-button>
+					<el-tag v-if="row.scope" :type="isGlobalRow(row) ? 'info' : 'warning'" size="small" effect="plain">
+						{{ sourceText(row) }}
+					</el-tag>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="操作" width="240" fixed="right">
+				<template #default="{ row }">
+					<!-- 平台默认行:租户侧只读,不给最终只会 404 的编辑 / 删除。 -->
+					<template v-if="isGlobalRow(row)">
+						<el-button v-if="supportsFactoryOverride" link type="primary" @click="createOverride(row)">自定义本工厂</el-button>
+						<span v-else class="scope-readonly">平台默认,不可修改</span>
+					</template>
+					<template v-else>
+						<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+						<el-button v-if="enableTest" link type="warning" @click="testRow(row)">测试</el-button>
+						<!-- 有平台默认可回落 → 恢复平台默认;本工厂自建 → 删除。 -->
+						<el-button v-if="isFactoryOverrideRow(row)" link type="warning" @click="resetToGlobal(row)">恢复平台默认</el-button>
+						<el-button v-else link type="danger" @click="removeRow(row)">删除</el-button>
+					</template>
 				</template>
 				</template>
 			</el-table-column>
 			</el-table-column>
 		</el-table>
 		</el-table>
@@ -222,6 +279,11 @@ onMounted(() => {
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
+.scope-readonly {
+	font-size: 12px;
+	color: var(--el-text-color-secondary);
+}
+
 .toolbar {
 .toolbar {
 	display: flex;
 	display: flex;
 	gap: 12px;
 	gap: 12px;

+ 2 - 0
Web/src/views/aidop/s8/config/S8ConfigHubPage.vue

@@ -24,6 +24,8 @@ const cards = [
 	{ path: '/aidop/s8/config/exception-types', title: '异常类型', desc: '类型编码、SLA、责任角色' },
 	{ path: '/aidop/s8/config/exception-types', title: '异常类型', desc: '类型编码、SLA、责任角色' },
 	{ path: '/aidop/s8/config/watch-rules', title: '监控规则', desc: '轮询节拍与表达式' },
 	{ path: '/aidop/s8/config/watch-rules', title: '监控规则', desc: '轮询节拍与表达式' },
 	{ path: '/aidop/s8/config/dashboard-cells', title: '大屏卡片', desc: '页面/卡片编码、绑定类型、指标与时间窗' },
 	{ path: '/aidop/s8/config/dashboard-cells', title: '大屏卡片', desc: '页面/卡片编码、绑定类型、指标与时间窗' },
+	// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:运营指标目标值维护入口。
+	{ path: '/aidop/s8/config/kpi-targets', title: 'S9 KPI 目标值配置', desc: '运营指标目标值,支持按工厂覆盖平台默认' },
 	{ path: '/aidop/s8/config/roles', title: '角色权限 / 操作员绑定', desc: '操作员与员工的绑定维护' },
 	{ path: '/aidop/s8/config/roles', title: '角色权限 / 操作员绑定', desc: '操作员与员工的绑定维护' },
 	// 演示阶段隐藏(保留代码,后续恢复取消注释即可):
 	// 演示阶段隐藏(保留代码,后续恢复取消注释即可):
 	// { path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级 + 推送渠道' },
 	// { path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级 + 推送渠道' },

+ 1 - 0
Web/src/views/aidop/s8/config/S8DashboardCellConfigPage.vue

@@ -166,6 +166,7 @@ function validateFormExtra(form: Record<string, unknown>): string | null {
 		subtitle="S8 / 配置 / 卡片数据绑定(page+cell、指标、时间窗);浮层「卡片配置」仍可覆盖展示标题、色调与静态预览数"
 		subtitle="S8 / 配置 / 卡片数据绑定(page+cell、指标、时间窗);浮层「卡片配置」仍可覆盖展示标题、色调与静态预览数"
 		endpoint="/api/aidop/s8/config/dashboard-cells"
 		endpoint="/api/aidop/s8/config/dashboard-cells"
 		:columns="columns"
 		:columns="columns"
+		:supports-factory-override="true"
 		:fields="fields"
 		:fields="fields"
 		:build-default="buildDefault"
 		:build-default="buildDefault"
 		:load-options="loadOptions"
 		:load-options="loadOptions"

+ 80 - 3
Web/src/views/aidop/s8/config/S8ExceptionTypeConfigPage.vue

@@ -257,8 +257,60 @@ async function handleSave() {
 	}
 	}
 }
 }
 
 
+// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:平台默认 / 当前工厂覆盖的业务语义。
+// 平台默认行租户侧只读——不渲染编辑 / 删除 / 启用开关,只提供「自定义本工厂」。
+function isGlobalRow(row: TreeRow): boolean {
+	return row.scope === 'GLOBAL';
+}
+/** 平台默认的工厂覆盖行(可「恢复平台默认」);false 表示本工厂自建行(可「删除」)。 */
+function isFactoryOverrideRow(row: TreeRow): boolean {
+	return row.scope === 'FACTORY' && row.hasGlobalDefault === true;
+}
+function sourceText(row: TreeRow): string {
+	// 防御:后端未下发 scope 时不得臆断为工厂行(否则会渲染出最终 404 的写按钮)。
+	if (!row.scope) return '—';
+	return isGlobalRow(row) ? '平台默认' : '当前工厂覆盖';
+}
+function slaText(row: TreeRow): string {
+	if (row.isGroup || row.isVirtual) return '—';
+	const v = row.slaMinutes;
+	if (v === null || v === undefined) return '—';
+	return Number(v) <= 0 ? '不启用' : `${v} 分钟`;
+}
+
+/** 自定义本工厂:复制平台默认为本工厂覆盖行,平台默认行不受影响。 */
+async function handleCreateOverride(row: TreeRow) {
+	try {
+		await s8ConfigApi.exceptionTypes.createFactoryOverride(row.id);
+		ElMessage.success('已创建当前工厂配置,可继续编辑');
+		await loadTree();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '自定义本工厂失败');
+	}
+}
+
+/** 恢复平台默认:只删除本工厂覆盖行,平台默认行不变。 */
+async function handleResetToGlobal(row: TreeRow) {
+	try {
+		await ElMessageBox.confirm(
+			`确认恢复「${row.typeName}」的平台默认配置?当前工厂的自定义配置将被移除。`,
+			'恢复平台默认',
+			{ type: 'warning' },
+		);
+	} catch {
+		return;
+	}
+	try {
+		await s8ConfigApi.exceptionTypes.resetToGlobalDefault(row.id);
+		ElMessage.success('已恢复平台默认');
+		await loadTree();
+	} catch (e: any) {
+		ElMessage.error(e?.response?.data?.message ?? '恢复平台默认失败');
+	}
+}
+
 async function handleToggleEnabled(row: TreeRow, next: boolean) {
 async function handleToggleEnabled(row: TreeRow, next: boolean) {
-	if (row.isGroup || row.isVirtual) return;
+	if (row.isGroup || row.isVirtual || isGlobalRow(row)) return;
 	try {
 	try {
 		await s8ConfigApi.exceptionTypes.setEnabled(row.id, next);
 		await s8ConfigApi.exceptionTypes.setEnabled(row.id, next);
 		row.enabled = next;
 		row.enabled = next;
@@ -326,27 +378,52 @@ onMounted(async () => {
 					<span v-else>{{ row.sceneCode || '—' }}</span>
 					<span v-else>{{ row.sceneCode || '—' }}</span>
 				</template>
 				</template>
 			</el-table-column>
 			</el-table-column>
+			<!-- S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:SLA 直接上列表,业务人员无需打开弹窗。 -->
+			<el-table-column label="SLA时限" width="110">
+				<template #default="{ row }">{{ slaText(row) }}</template>
+			</el-table-column>
+			<el-table-column label="配置来源" width="120">
+				<template #default="{ row }">
+					<span v-if="row.isGroup || row.isVirtual">—</span>
+					<el-tag v-else :type="isGlobalRow(row) ? 'info' : 'warning'" size="small" effect="plain">
+						{{ sourceText(row) }}
+					</el-tag>
+				</template>
+			</el-table-column>
 			<el-table-column label="启用" width="90" align="center">
 			<el-table-column label="启用" width="90" align="center">
 				<template #default="{ row }">
 				<template #default="{ row }">
+					<!-- 平台默认行只读展示,不给最终只会 404 的开关。 -->
 					<el-switch
 					<el-switch
-						v-if="!row.isGroup && !row.isVirtual"
+						v-if="!row.isGroup && !row.isVirtual && !isGlobalRow(row)"
 						:model-value="row.enabled"
 						:model-value="row.enabled"
 						@change="(v: any) => handleToggleEnabled(row, !!v)"
 						@change="(v: any) => handleToggleEnabled(row, !!v)"
 					/>
 					/>
+					<span v-else-if="!row.isGroup && !row.isVirtual">{{ row.enabled ? '启用' : '停用' }}</span>
 					<span v-else>—</span>
 					<span v-else>—</span>
 				</template>
 				</template>
 			</el-table-column>
 			</el-table-column>
 			<el-table-column prop="sortNo" label="排序" width="80">
 			<el-table-column prop="sortNo" label="排序" width="80">
 				<template #default="{ row }">{{ row.isVirtual ? '—' : row.sortNo }}</template>
 				<template #default="{ row }">{{ row.isVirtual ? '—' : row.sortNo }}</template>
 			</el-table-column>
 			</el-table-column>
-			<el-table-column label="操作" width="240">
+			<el-table-column label="操作" width="260">
 				<template #default="{ row }">
 				<template #default="{ row }">
+					<!-- 分组父节点:平台统一维护,不提供任何租户侧写动作(含自定义本工厂)。 -->
 					<template v-if="row.isGroup && !row.isVirtual">
 					<template v-if="row.isGroup && !row.isVirtual">
 						<el-button size="small" type="primary" link @click="openCreate(row.id)">在此分组下新建</el-button>
 						<el-button size="small" type="primary" link @click="openCreate(row.id)">在此分组下新建</el-button>
 					</template>
 					</template>
 					<template v-else-if="row.isVirtual">
 					<template v-else-if="row.isVirtual">
 						<span class="config-hint">未归类节点无法在此新增;调整子节点 parentId 后将自动归类。</span>
 						<span class="config-hint">未归类节点无法在此新增;调整子节点 parentId 后将自动归类。</span>
 					</template>
 					</template>
+					<!-- 平台默认行(或后端未下发 scope 的兜底):只读,仅可「自定义本工厂」。 -->
+					<template v-else-if="isGlobalRow(row) || !row.scope">
+						<el-button size="small" type="primary" link @click="handleCreateOverride(row)">自定义本工厂</el-button>
+					</template>
+					<!-- 平台默认的工厂覆盖行:可编辑 / 恢复平台默认。 -->
+					<template v-else-if="isFactoryOverrideRow(row)">
+						<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
+						<el-button size="small" type="warning" link @click="handleResetToGlobal(row)">恢复平台默认</el-button>
+					</template>
+					<!-- 本工厂自建行:无平台默认可回落,保留删除。 -->
 					<template v-else>
 					<template v-else>
 						<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
 						<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
 						<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
 						<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>

+ 179 - 0
Web/src/views/aidop/s8/config/S8KpiTargetConfigPage.vue

@@ -0,0 +1,179 @@
+<script setup lang="ts" name="aidopS8KpiTargetConfig">
+// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:S9 KPI 目标值配置。
+// 只维护 5 项 Result KPI 的目标值(TargetRatio)。
+// 严格不展示 / 不维护:KPI 当前值、enabled、TenantId、FactoryId、0/0、DB Id、data_source、技术 scope code。
+// 作用域语义:平台默认(租户侧只读)→ 自定义本工厂 → 恢复平台默认;生效读取为工厂覆盖优先。
+import { computed, onMounted, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import {
+	getS8KpiTargets,
+	upsertS8KpiTargetOverride,
+	resetS8KpiTargetToGlobal,
+	type S8KpiTargetItem,
+} from '../api/s8KpiTargetApi';
+
+const rows = ref<S8KpiTargetItem[]>([]);
+const loading = ref(false);
+
+const dialogVisible = ref(false);
+const saving = ref(false);
+const editing = ref<S8KpiTargetItem | null>(null);
+const formTarget = ref<number | undefined>(undefined);
+const formRemark = ref<string>('');
+
+/** 配置来源业务文案:只允许「平台默认 / 当前工厂覆盖」,不暴露 GLOBAL/FACTORY/0-0。 */
+function sourceText(row: S8KpiTargetItem): string {
+	return row.hasFactoryOverride ? '当前工厂覆盖' : '平台默认';
+}
+
+/** 目标值展示:未配置统一为「待配置」,不显示 0% / -- / NaN。 */
+function targetText(v: number | null | undefined, unit: string): string {
+	if (v === null || v === undefined || Number.isNaN(Number(v))) return '待配置';
+	return `${Number(v)}${unit || '%'}`;
+}
+
+const dialogTitle = computed(() =>
+	editing.value?.hasFactoryOverride ? '编辑本工厂目标值' : '自定义本工厂目标值',
+);
+
+async function load() {
+	loading.value = true;
+	try {
+		rows.value = await getS8KpiTargets();
+	} catch (err) {
+		ElMessage.error(err instanceof Error ? err.message : '目标值配置加载失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function openDialog(row: S8KpiTargetItem) {
+	editing.value = row;
+	// 自定义本工厂时以平台默认值为起点,业务人员在此基础上调整。
+	formTarget.value = row.targetRatio ?? row.globalTargetRatio ?? undefined;
+	formRemark.value = row.remark ?? '';
+	dialogVisible.value = true;
+}
+
+async function save() {
+	if (!editing.value) return;
+	const v = formTarget.value;
+	if (v === undefined || v === null || Number.isNaN(Number(v))) {
+		ElMessage.warning('请填写目标值');
+		return;
+	}
+	if (Number(v) < 0 || Number(v) > 100) {
+		ElMessage.warning('目标值必须在 0–100 之间');
+		return;
+	}
+	saving.value = true;
+	try {
+		await upsertS8KpiTargetOverride(editing.value.metricCode, {
+			targetRatio: Number(v),
+			remark: formRemark.value?.trim() ? formRemark.value.trim() : null,
+		});
+		ElMessage.success('已保存为当前工厂目标值');
+		dialogVisible.value = false;
+		await load();
+	} catch (err) {
+		ElMessage.error(err instanceof Error ? err.message : '保存失败');
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function resetToGlobal(row: S8KpiTargetItem) {
+	await ElMessageBox.confirm(
+		`确认恢复「${row.metricName}」的平台默认目标值?当前工厂的自定义配置将被移除。`,
+		'恢复平台默认',
+		{ type: 'warning' },
+	);
+	try {
+		await resetS8KpiTargetToGlobal(row.metricCode);
+		ElMessage.success('已恢复平台默认');
+		await load();
+	} catch (err) {
+		ElMessage.error(err instanceof Error ? err.message : '恢复失败');
+	}
+}
+
+onMounted(load);
+</script>
+
+<template>
+	<AidopDemoShell title="S9 KPI 目标值配置" subtitle="S8 / 配置 / 运营指标目标值">
+		<div class="kpi-cfg__hint">
+			平台默认目标由平台统一维护,此处只读;如需按本工厂调整,请使用「自定义本工厂」。
+			工厂目标优先于平台默认生效。
+		</div>
+
+		<el-table :data="rows" v-loading="loading" border stripe>
+			<el-table-column prop="metricName" label="指标名称" min-width="200" show-overflow-tooltip />
+			<el-table-column label="目标值" width="140">
+				<template #default="{ row }">
+					<span :class="{ 'kpi-cfg__unset': row.targetRatio === null || row.targetRatio === undefined }">
+						{{ targetText(row.targetRatio, row.unit) }}
+					</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="配置来源" width="140">
+				<template #default="{ row }">
+					<el-tag :type="row.hasFactoryOverride ? 'warning' : 'info'" size="small" effect="plain">
+						{{ sourceText(row) }}
+					</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="remark" label="说明" min-width="220" show-overflow-tooltip>
+				<template #default="{ row }">{{ row.remark || '—' }}</template>
+			</el-table-column>
+			<el-table-column label="操作" width="230" fixed="right">
+				<template #default="{ row }">
+					<template v-if="row.hasFactoryOverride">
+						<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
+						<el-button link type="warning" @click="resetToGlobal(row)">恢复平台默认</el-button>
+					</template>
+					<!-- 平台默认行:租户侧只读,不提供最终只会失败的「编辑」动作。 -->
+					<el-button v-else link type="primary" @click="openDialog(row)">自定义本工厂</el-button>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
+			<el-form v-if="editing" label-width="120px">
+				<el-form-item label="指标名称">
+					<span>{{ editing.metricName }}</span>
+				</el-form-item>
+				<el-form-item label="平台默认目标">
+					<span>{{ targetText(editing.globalTargetRatio, editing.unit) }}</span>
+				</el-form-item>
+				<el-form-item label="本工厂目标" required>
+					<el-input-number v-model="formTarget" :min="0" :max="100" :precision="2" :step="1" />
+					<span class="kpi-cfg__unit">{{ editing.unit || '%' }}</span>
+				</el-form-item>
+				<el-form-item label="说明">
+					<el-input v-model="formRemark" type="textarea" :rows="2" maxlength="500" show-word-limit />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" @click="save">保存</el-button>
+			</template>
+		</el-dialog>
+	</AidopDemoShell>
+</template>
+
+<style scoped>
+.kpi-cfg__hint {
+	margin-bottom: 12px;
+	font-size: 13px;
+	color: var(--el-text-color-secondary);
+}
+.kpi-cfg__unset {
+	color: var(--el-text-color-secondary);
+}
+.kpi-cfg__unit {
+	margin-left: 8px;
+	color: var(--el-text-color-secondary);
+}
+</style>

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.398</AssemblyVersion>
-    <FileVersion>1.0.398</FileVersion>
-    <Version>1.0.398</Version>
+    <AssemblyVersion>1.0.400</AssemblyVersion>
+    <FileVersion>1.0.400</FileVersion>
+    <Version>1.0.400</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -541,6 +541,12 @@
     <None Update="UpdateScripts\1.0.393.sql">
     <None Update="UpdateScripts\1.0.393.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.399.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.400.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 66 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.399.sql

@@ -0,0 +1,66 @@
+-- ============================================================================
+-- 1.0.399.sql:S8 S9 KPI 平台默认目标值初始化(纯数据 UPDATE)
+--
+-- 批次:S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1
+-- 用途:把 S9 Result KPI 的「平台默认目标值」写入 DB,使 DB 成为目标值的唯一事实源。
+--
+-- 背景:目标值此前只存在于 C# `_demoBaselineMap`(95/95/98/95)与内联字面量(90.0m),
+--       而 ado_s8_monitor_metric.default_target_ratio 列虽早已存在却 13 行全 NULL。
+--       本批已把读取路径切到 DB(工厂覆盖 > 平台默认,未配置返回 null 不做常量兜底),
+--       因此必须由本脚本完成平台默认值的权威初始化,否则页面目标值会全部变成「待配置」。
+--
+-- 红线:
+--   SCHEMA CHANGE = NO / DDL = NO —— 全文件只有 UPDATE,无 CREATE/ALTER/DROP/TRUNCATE/DELETE。
+--   只更新平台默认行(tenant_id=0 AND factory_id=0),不触碰任何工厂覆盖行。
+--   幂等:附加 `default_target_ratio IS NULL` 守卫,重复执行不改已有值,
+--         也不会覆盖运维/业务后续调整过的平台默认目标。
+--
+-- 数值来源:本批之前 C# 硬编码的既有目标值,逐项等值搬迁,不新增、不调整业务口径:
+--   ORDER_DELIVERY_RATE    95   (原 _demoBaselineMap)
+--   PO_DELIVERY_RATE       95   (原 _demoBaselineMap)
+--   IQC_PASS_RATE          98   (原 _demoBaselineMap)
+--   WO_COMPLETION_RATE     95   (原 _demoBaselineMap)
+--   ORDER_FLOW_CYCLE_RATIO 90   (原 C# 内联 90.0m)
+--
+-- 不在本脚本范围(刻意不做):
+--   - KPI 当前值 92/88/96/90 —— 不是配置项,不写入本列,不删除,不改动。
+--   - enabled 列 —— 5 行当前为 0,但 Result KPI 读取路径不过滤 Enabled;
+--     改动它会影响 monitor-options 下拉可选项,超出本批范围,留待业务确认。
+-- ============================================================================
+
+UPDATE `ado_s8_monitor_metric`
+SET `default_target_ratio` = 95.00, `updated_at` = NOW()
+WHERE `tenant_id` = 0 AND `factory_id` = 0
+  AND `metric_code` = 'ORDER_DELIVERY_RATE'
+  AND `default_target_ratio` IS NULL;
+
+UPDATE `ado_s8_monitor_metric`
+SET `default_target_ratio` = 95.00, `updated_at` = NOW()
+WHERE `tenant_id` = 0 AND `factory_id` = 0
+  AND `metric_code` = 'PO_DELIVERY_RATE'
+  AND `default_target_ratio` IS NULL;
+
+UPDATE `ado_s8_monitor_metric`
+SET `default_target_ratio` = 98.00, `updated_at` = NOW()
+WHERE `tenant_id` = 0 AND `factory_id` = 0
+  AND `metric_code` = 'IQC_PASS_RATE'
+  AND `default_target_ratio` IS NULL;
+
+UPDATE `ado_s8_monitor_metric`
+SET `default_target_ratio` = 95.00, `updated_at` = NOW()
+WHERE `tenant_id` = 0 AND `factory_id` = 0
+  AND `metric_code` = 'WO_COMPLETION_RATE'
+  AND `default_target_ratio` IS NULL;
+
+UPDATE `ado_s8_monitor_metric`
+SET `default_target_ratio` = 90.00, `updated_at` = NOW()
+WHERE `tenant_id` = 0 AND `factory_id` = 0
+  AND `metric_code` = 'ORDER_FLOW_CYCLE_RATIO'
+  AND `default_target_ratio` IS NULL;
+
+-- 验收查询(人工执行,脚本本身不依赖):
+--   SELECT metric_code, default_target_ratio
+--   FROM ado_s8_monitor_metric
+--   WHERE tenant_id = 0 AND factory_id = 0 AND mechanism = 'RATIO' AND is_result_kpi = 1
+--   ORDER BY sort_no;
+--   期望 5 行:95.00 / 95.00 / 98.00 / 95.00 / 90.00,且其余 8 行 default_target_ratio 保持 NULL。

+ 53 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.400.sql

@@ -0,0 +1,53 @@
+-- ============================================================================
+-- 1.0.400.sql:S8 S9 KPI 目标值配置菜单(存量库补行)
+--
+-- 批次:S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1
+-- 用途:为存量库补「S9 KPI 目标值配置」菜单行。
+--       新库由 SysMenuSeedData.cs 的 cfg 数组((19, "/aidop/s8/config/kpi-targets", ...))播种;
+--       已初始化过的库不会重跑 seed,故需本脚本补齐,二者 Id / Path / Component 保持一致。
+--
+-- Id 依据:S8 菜单号段 baseId = 1329008000000(SysMenuSeedData.cs:1085),
+--          config 子页占 baseId+11..18(实测 DB 已存在 …011~…018),本行取 baseId+19 = 1329008000019。
+--          执行前实测该 Id 未被占用。
+--
+-- 红线:
+--   SCHEMA CHANGE = NO / DDL = NO —— 无 CREATE/ALTER/DROP/TRUNCATE。
+--   不 DELETE、不 UPDATE 任何既有菜单行;只插入这一行。
+--   幂等:INSERT ... SELECT ... WHERE NOT EXISTS,重复执行不产生第二行。
+--
+-- 与既有 config 子页保持同构:Pid = S8 目录、Type=2(Menu)、IsHide=1(不出侧栏,
+--   由「配置中心」Hub 卡片进入,与 …011~…018 同口径)、OrderNo = 100+19。
+-- ============================================================================
+
+-- 列清单与 SysMenu 实表逐列核对过(该表无 IsDelete 列);
+-- NOT NULL 且无默认值的列 Id/Pid/Type/Title/IsIframe/IsHide/IsKeepAlive/IsAffix/OrderNo/Status 全部显式给值,
+-- 取值与既有同级行 1329008000018(大屏卡片配置)一致。
+INSERT INTO `SysMenu`
+  (`Id`, `Pid`, `Type`, `Name`, `Path`, `Component`, `Title`, `Icon`,
+   `IsIframe`, `IsHide`, `IsKeepAlive`, `IsAffix`, `OrderNo`, `Status`, `Remark`, `CreateTime`)
+SELECT
+  1329008000019,
+  1321000009000,
+  2,
+  'aidopS8KpiTargetConfig',
+  '/aidop/s8/config/kpi-targets',
+  '/aidop/s8/config/S8KpiTargetConfigPage',
+  'S9 KPI 目标值配置',
+  'ele-Document',
+  0,
+  1,
+  1,
+  0,
+  119,
+  1,
+  'S8 S9 KPI 目标值配置',
+  NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM (SELECT `Id` FROM `SysMenu` WHERE `Id` = 1329008000019) AS t
+);
+
+-- 验收查询(人工执行):
+--   SELECT Id, Title, Path, Component, Type, IsHide, OrderNo
+--   FROM SysMenu WHERE Path LIKE '/aidop/s8/config/%' ORDER BY Id;
+--   期望新增 1 行 1329008000019 / S9 KPI 目标值配置 / IsHide=1,其余 8 行不变。

+ 25 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigDashboardCellsController.cs

@@ -50,4 +50,29 @@ public class AdoS8ConfigDashboardCellsController : ControllerBase
         catch (S8NotFoundException) { return NotFound(); }
         catch (S8NotFoundException) { return NotFound(); }
         return Ok();
         return Ok();
     }
     }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂。
+    /// 入参是<b>平台默认行</b>的 Id;服务端复制其业务字段并盖章可信租户 / 工厂,存为工厂覆盖行。
+    /// 平台默认行不被修改。
+    /// </summary>
+    [HttpPost("{globalId:long}/factory-override")]
+    public async Task<IActionResult> CreateFactoryOverrideAsync(long globalId)
+    {
+        try { return Ok(await _svc.CreateFactoryOverrideAsync(globalId, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
+    /// 入参是<b>当前工厂覆盖行</b>的 Id;只删除该覆盖行,平台默认行不受影响。
+    /// </summary>
+    [HttpDelete("{overrideId:long}/factory-override")]
+    public async Task<IActionResult> ResetToGlobalDefaultAsync(long overrideId)
+    {
+        try { await _svc.ResetToGlobalDefaultAsync(overrideId, await _scope.ResolveAsync()); return Ok(new { overrideId, reset = true }); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }
 }

+ 25 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigExceptionTypesController.cs

@@ -84,4 +84,29 @@ public class AdoS8ConfigExceptionTypesController : ControllerBase
         catch (S8NotFoundException) { return NotFound(); }
         catch (S8NotFoundException) { return NotFound(); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
     }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂。
+    /// 入参是<b>平台默认行</b>的 Id;服务端复制其业务字段并盖章可信租户 / 工厂,存为工厂覆盖行。
+    /// 平台默认行不被修改。
+    /// </summary>
+    [HttpPost("{globalId:long}/factory-override")]
+    public async Task<IActionResult> CreateFactoryOverrideAsync(long globalId)
+    {
+        try { return Ok(await _svc.CreateFactoryOverrideAsync(globalId, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
+    /// 入参是<b>当前工厂覆盖行</b>的 Id;只删除该覆盖行,平台默认行不受影响。
+    /// </summary>
+    [HttpDelete("{overrideId:long}/factory-override")]
+    public async Task<IActionResult> ResetToGlobalDefaultAsync(long overrideId)
+    {
+        try { await _svc.ResetToGlobalDefaultAsync(overrideId, await _scope.ResolveAsync()); return Ok(new { overrideId, reset = true }); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
 }
 }

+ 63 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigKpiTargetsController.cs

@@ -0,0 +1,63 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+/// <summary>
+/// S9 KPI 目标值配置(S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1)。
+/// 复用 ado_s8_monitor_metric.default_target_ratio;只维护目标值,不涉及 KPI 当前值。
+/// 作用域:平台默认租户侧只读 → 自定义本工厂 → 恢复平台默认。
+/// </summary>
+[ApiController]
+[Route("api/aidop/s8/config/kpi-targets")]
+[NonUnify]
+public class AdoS8ConfigKpiTargetsController : ControllerBase
+{
+    private readonly S8KpiTargetConfigService _svc;
+    private readonly S8TrustedScopeResolver _scope;
+
+    public AdoS8ConfigKpiTargetsController(S8KpiTargetConfigService svc, S8TrustedScopeResolver scope)
+    {
+        _svc = svc;
+        _scope = scope;
+    }
+
+    /// <summary>列表:5 项 Result KPI 的生效目标值(工厂覆盖优先)。</summary>
+    [HttpGet]
+    public async Task<IActionResult> ListAsync()
+        => Ok(await _svc.ListAsync(await _scope.ResolveAsync()));
+
+    /// <summary>
+    /// 自定义本工厂 / 编辑本工厂目标值。按 metricCode 定位,归属由服务端盖章。
+    /// </summary>
+    [HttpPut("{metricCode}/factory-override")]
+    public async Task<IActionResult> UpsertFactoryOverrideAsync(string metricCode, [FromBody] AdoS8KpiTargetUpdateBody body)
+    {
+        try
+        {
+            return Ok(await _svc.UpsertFactoryOverrideAsync(
+                metricCode, body?.TargetRatio ?? -1m, body?.Remark, await _scope.ResolveAsync()));
+        }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
+    /// <summary>恢复平台默认:只删当前工厂覆盖行,平台默认行不受影响。</summary>
+    [HttpDelete("{metricCode}/factory-override")]
+    public async Task<IActionResult> ResetToGlobalDefaultAsync(string metricCode)
+    {
+        try { return Ok(await _svc.ResetToGlobalDefaultAsync(metricCode, await _scope.ResolveAsync())); }
+        catch (S8NotFoundException) { return NotFound(); }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+}
+
+/// <summary>S9 KPI 目标值编辑入参。只接受目标值与说明,归属字段一律不接受。</summary>
+public class AdoS8KpiTargetUpdateBody
+{
+    /// <summary>目标值(百分比,0–100)。</summary>
+    public decimal TargetRatio { get; set; }
+
+    /// <summary>说明(可选)。</summary>
+    public string? Remark { get; set; }
+}

+ 43 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -520,6 +520,17 @@ public class AdoS8ExceptionTypeTreeNodeDto
     public string? Remark { get; set; }
     public string? Remark { get; set; }
     public DateTime CreatedAt { get; set; }
     public DateTime CreatedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域标识。
+    /// GLOBAL = 平台默认(租户侧只读);FACTORY = 当前工厂行。
+    /// 树端点与列表端点必须一致提供,否则前端无法区分只读 / 可写。
+    /// </summary>
+    public string Scope { get; set; } = "GLOBAL";
+
+    /// <summary>该业务键是否存在平台默认行:true → 工厂行可「恢复平台默认」;false → 本工厂自建,可删除。</summary>
+    public bool HasGlobalDefault { get; set; }
+
     public List<AdoS8ExceptionTypeTreeNodeDto> Children { get; set; } = new();
     public List<AdoS8ExceptionTypeTreeNodeDto> Children { get; set; } = new();
 }
 }
 
 
@@ -550,3 +561,35 @@ public class AdoS8DimensionNodeDto
     public string? Remark { get; set; }
     public string? Remark { get; set; }
     public List<AdoS8DimensionNodeDto> Children { get; set; } = new();
     public List<AdoS8DimensionNodeDto> Children { get; set; } = new();
 }
 }
+
+/// <summary>
+/// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:S9 KPI 目标值配置行(业务视图)。
+/// 只承载目标值配置所需字段;不暴露 TenantId / FactoryId / DB Id / data_source 等技术字段。
+/// KPI 当前值(CurrentValue)不属于配置项,故本 DTO 不含该字段。
+/// </summary>
+public class AdoS8KpiTargetItemDto
+{
+    /// <summary>指标编码(业务键,用于定位配置行;前端不展示)。</summary>
+    public string MetricCode { get; set; } = string.Empty;
+
+    /// <summary>指标名称(业务展示名)。</summary>
+    public string MetricName { get; set; } = string.Empty;
+
+    /// <summary>单位,默认 %。</summary>
+    public string Unit { get; set; } = "%";
+
+    /// <summary>生效目标值:工厂覆盖优先,无覆盖则平台默认;均未配置时为 null。</summary>
+    public decimal? TargetRatio { get; set; }
+
+    /// <summary>平台默认目标值(供「恢复平台默认」预览)。</summary>
+    public decimal? GlobalTargetRatio { get; set; }
+
+    /// <summary>说明。</summary>
+    public string? Remark { get; set; }
+
+    /// <summary>作用域:GLOBAL = 平台默认;FACTORY = 当前工厂覆盖。</summary>
+    public string Scope { get; set; } = "GLOBAL";
+
+    /// <summary>当前工厂是否已有覆盖配置。</summary>
+    public bool HasFactoryOverride { get; set; }
+}

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8DashboardCellConfig.cs

@@ -92,4 +92,21 @@ public class AdoS8DashboardCellConfig
 
 
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     public DateTime? UpdatedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域标识(不落库)。
+    /// GLOBAL = 平台默认(租户侧只读);FACTORY = 当前工厂覆盖(可编辑 / 可恢复默认)。
+    /// 前端据此渲染「生效范围」与可用操作,无需读取 tenant_id / factory_id。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string Scope => Admin.NET.Plugin.AiDOP.Infrastructure.S8ConfigScope.Resolve(TenantId, FactoryId);
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:本行对应的业务键是否存在平台默认行(不落库,由 List 赋值)。
+    /// 用于前端区分两种工厂行:
+    ///   true  —— 平台默认的工厂覆盖:可「编辑」/「恢复平台默认」。
+    ///   false —— 本工厂自建配置:可「编辑」/「删除」(没有平台默认可回落)。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public bool HasGlobalDefault { get; set; }
 }
 }

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8ExceptionType.cs

@@ -86,4 +86,21 @@ public class AdoS8ExceptionType
 
 
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     public DateTime? UpdatedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域标识(不落库)。
+    /// GLOBAL = 平台默认(租户侧只读);FACTORY = 当前工厂覆盖(可编辑 / 可恢复默认)。
+    /// 前端据此渲染「生效范围」与可用操作,无需读取 tenant_id / factory_id。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string Scope => Admin.NET.Plugin.AiDOP.Infrastructure.S8ConfigScope.Resolve(TenantId, FactoryId);
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:本行对应的业务键是否存在平台默认行(不落库,由 List 赋值)。
+    /// 用于前端区分两种工厂行:
+    ///   true  —— 平台默认的工厂覆盖:可「编辑」/「恢复平台默认」。
+    ///   false —— 本工厂自建配置:可「编辑」/「删除」(没有平台默认可回落)。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public bool HasGlobalDefault { get; set; }
 }
 }

+ 8 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8MonitorMetric.cs

@@ -90,4 +90,12 @@ public class AdoS8MonitorMetric
 
 
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
     public DateTime? UpdatedAt { get; set; }
     public DateTime? UpdatedAt { get; set; }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域标识(不落库)。
+    /// GLOBAL = 平台默认(租户侧只读);FACTORY = 当前工厂覆盖(可编辑 / 可恢复默认)。
+    /// 前端据此渲染「生效范围」与可用操作,无需读取 tenant_id / factory_id。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string Scope => Admin.NET.Plugin.AiDOP.Infrastructure.S8ConfigScope.Resolve(TenantId, FactoryId);
 }
 }

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8ConfigScope.cs

@@ -0,0 +1,39 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:S8 配置对象的「平台默认 / 工厂覆盖」统一语义。
+///
+/// 生效读取顺序:工厂覆盖 &gt; 平台默认。
+///
+/// 平台默认(tenant_id = 0 且 factory_id = 0):
+///   租户 / 工厂管理员 <b>只读</b>——不允许 Update / Delete,也不允许在页面上给出最终只会 404 的编辑动作;
+///   需要调整时走「自定义本工厂」:复制平台默认的业务字段 → 由服务端盖章可信租户 / 工厂 → 存为工厂覆盖行。
+///
+/// 工厂覆盖(tenant_id / factory_id = 当前可信作用域):
+///   可编辑、可删除;删除即「恢复平台默认」,平台默认行本身不受影响。
+///
+/// 平台默认行的维护能力与租户侧维护分离;在没有明确的平台级权限模型之前,
+/// 本层只保证租户侧只读,<b>不为了方便重新开放 global write</b>。
+/// </summary>
+public static class S8ConfigScope
+{
+    /// <summary>平台默认行的租户 Id。</summary>
+    public const long GlobalTenantId = 0L;
+
+    /// <summary>平台默认行的工厂 Id。</summary>
+    public const long GlobalFactoryId = 0L;
+
+    /// <summary>作用域标识:平台默认。</summary>
+    public const string Global = "GLOBAL";
+
+    /// <summary>作用域标识:当前工厂覆盖。</summary>
+    public const string Factory = "FACTORY";
+
+    /// <summary>是否为平台默认行。</summary>
+    public static bool IsGlobal(long tenantId, long factoryId) =>
+        tenantId == GlobalTenantId && factoryId == GlobalFactoryId;
+
+    /// <summary>把归属列翻译成作用域标识,供前端按业务语言展示(不暴露 tenantId / factoryId 本身)。</summary>
+    public static string Resolve(long tenantId, long factoryId) =>
+        IsGlobal(tenantId, factoryId) ? Global : Factory;
+}

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

@@ -1259,6 +1259,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),
             (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),
             (17, "/aidop/s8/config/exception-types", "aidopS8ExceptionTypeConfig", "异常类型配置", "/aidop/s8/config/S8ExceptionTypeConfigPage"),
             (18, "/aidop/s8/config/dashboard-cells", "aidopS8DashboardCellConfig", "大屏卡片配置", "/aidop/s8/config/S8DashboardCellConfigPage"),
             (18, "/aidop/s8/config/dashboard-cells", "aidopS8DashboardCellConfig", "大屏卡片配置", "/aidop/s8/config/S8DashboardCellConfigPage"),
+            // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:S9 KPI 目标值配置(只维护 Result KPI 目标值)。
+            (19, "/aidop/s8/config/kpi-targets", "aidopS8KpiTargetConfig", "S9 KPI 目标值配置", "/aidop/s8/config/S8KpiTargetConfigPage"),
         };
         };
         foreach (var (off, path, name, title, component) in cfg)
         foreach (var (off, path, name, title, component) in cfg)
         {
         {

+ 70 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardCellConfigService.cs

@@ -21,13 +21,25 @@ public class S8DashboardCellConfigService : ITransient
                      || (x.TenantId == tenantId && x.FactoryId == factoryId))
                      || (x.TenantId == tenantId && x.FactoryId == factoryId))
             .ToListAsync();
             .ToListAsync();
 
 
-        return all
+        // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:同 (page_code, cell_code) 取工厂覆盖优先的唯一有效行,
+        // 并标记该业务键是否存在平台默认,供前端区分「工厂覆盖」与「本工厂自建」。
+        var globalKeys = all
+            .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
+            .Select(x => (x.PageCode, x.CellCode))
+            .ToHashSet();
+
+        var effective = all
             .GroupBy(x => (x.PageCode, x.CellCode))
             .GroupBy(x => (x.PageCode, x.CellCode))
             .Select(g => g.OrderByDescending(x => x.FactoryId).First())
             .Select(g => g.OrderByDescending(x => x.FactoryId).First())
             .OrderBy(x => x.PageCode)
             .OrderBy(x => x.PageCode)
             .ThenBy(x => x.SortNo)
             .ThenBy(x => x.SortNo)
             .ThenBy(x => x.CellCode)
             .ThenBy(x => x.CellCode)
             .ToList();
             .ToList();
+
+        foreach (var row in effective)
+            row.HasGlobalDefault = globalKeys.Contains((row.PageCode, row.CellCode));
+
+        return effective;
     }
     }
 
 
     // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
     // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
@@ -76,6 +88,63 @@ public class S8DashboardCellConfigService : ITransient
         await _rep.DeleteByIdAsync(e.Id);
         await _rep.DeleteByIdAsync(e.Id);
     }
     }
 
 
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂(创建工厂覆盖)。
+    /// 复制平台默认行业务字段 → 服务端盖章可信租户 / 工厂 → 存为工厂覆盖行;平台默认行不被修改。
+    /// 已存在同 (page_code, cell_code) 工厂覆盖时幂等返回既有行。
+    /// </summary>
+    public async Task<AdoS8DashboardCellConfig> CreateFactoryOverrideAsync(long globalId, S8TrustedScope scope)
+    {
+        var global = await _rep.AsQueryable()
+            .Where(x => x.Id == globalId
+                        && x.TenantId == S8ConfigScope.GlobalTenantId
+                        && x.FactoryId == S8ConfigScope.GlobalFactoryId)
+            .FirstAsync() ?? throw new S8NotFoundException("平台默认配置不存在");
+
+        var existing = await _rep.AsQueryable()
+            .Where(x => x.PageCode == global.PageCode && x.CellCode == global.CellCode
+                        && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .FirstAsync();
+        if (existing != null) return existing;
+
+        var copy = new AdoS8DashboardCellConfig
+        {
+            Id = 0,
+            TenantId = scope.TenantId,
+            FactoryId = scope.FactoryId,
+            PageCode = global.PageCode,
+            CellCode = global.CellCode,
+            CellTitle = global.CellTitle,
+            Icon = global.Icon,
+            LayoutArea = global.LayoutArea,
+            DisplayMode = global.DisplayMode,
+            SortNo = global.SortNo,
+            BindingType = global.BindingType,
+            ExceptionTypeCode = global.ExceptionTypeCode,
+            AggregateScope = global.AggregateScope,
+            StatMetric = global.StatMetric,
+            TimeWindow = global.TimeWindow,
+            DeptGroupBy = global.DeptGroupBy,
+            ShowInSidebar = global.ShowInSidebar,
+            FilterExpression = global.FilterExpression,
+            Enabled = global.Enabled,
+            CreatedAt = DateTime.Now,
+            UpdatedAt = null,
+        };
+        copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
+        return copy;
+    }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
+    /// 只删除当前工厂覆盖行,平台默认行不受影响。
+    /// </summary>
+    public async Task ResetToGlobalDefaultAsync(long overrideId, S8TrustedScope scope)
+    {
+        var e = await LoadScopedAsync(overrideId, scope);
+        await _rep.DeleteByIdAsync(e.Id);
+    }
+
     private async Task<AdoS8DashboardCellConfig> LoadScopedAsync(long id, S8TrustedScope scope) =>
     private async Task<AdoS8DashboardCellConfig> LoadScopedAsync(long id, S8TrustedScope scope) =>
         await _rep.AsQueryable()
         await _rep.AsQueryable()
             .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
             .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)

+ 79 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionTypeService.cs

@@ -27,12 +27,24 @@ public class S8ExceptionTypeService : ITransient
             .WhereIF(enabledOnly == true, x => x.Enabled)
             .WhereIF(enabledOnly == true, x => x.Enabled)
             .ToListAsync();
             .ToListAsync();
 
 
-        return all
+        // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:同 type_code 取工厂覆盖优先的唯一有效行,
+        // 并标记该业务键是否存在平台默认,供前端区分「工厂覆盖」与「本工厂自建」。
+        var globalCodes = all
+            .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
+            .Select(x => x.TypeCode)
+            .ToHashSet();
+
+        var effective = all
             .GroupBy(x => x.TypeCode)
             .GroupBy(x => x.TypeCode)
             .Select(g => g.OrderByDescending(x => x.FactoryId).First())
             .Select(g => g.OrderByDescending(x => x.FactoryId).First())
             .OrderBy(x => x.SortNo)
             .OrderBy(x => x.SortNo)
             .ThenBy(x => x.TypeCode)
             .ThenBy(x => x.TypeCode)
             .ToList();
             .ToList();
+
+        foreach (var row in effective)
+            row.HasGlobalDefault = globalCodes.Contains(row.TypeCode);
+
+        return effective;
     }
     }
 
 
     public async Task<AdoS8ExceptionType?> GetByCodeAsync(long tenantId, long factoryId, string typeCode)
     public async Task<AdoS8ExceptionType?> GetByCodeAsync(long tenantId, long factoryId, string typeCode)
@@ -165,6 +177,68 @@ public class S8ExceptionTypeService : ITransient
         await _rep.DeleteByIdAsync(e.Id);
         await _rep.DeleteByIdAsync(e.Id);
     }
     }
 
 
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂(创建工厂覆盖)。
+    /// 复制平台默认行的业务字段 → 由服务端盖章可信租户 / 工厂 → 存为工厂覆盖行;平台默认行不被修改。
+    /// 已存在同 type_code 的工厂覆盖时直接返回既有行(幂等,避免重复点击产生第二条覆盖)。
+    /// 分组父节点由 Seed 维护,不允许被工厂覆盖。
+    /// </summary>
+    public async Task<AdoS8ExceptionType> CreateFactoryOverrideAsync(long globalId, S8TrustedScope scope)
+    {
+        var global = await _rep.AsQueryable()
+            .Where(x => x.Id == globalId
+                        && x.TenantId == S8ConfigScope.GlobalTenantId
+                        && x.FactoryId == S8ConfigScope.GlobalFactoryId)
+            .FirstAsync() ?? throw new S8NotFoundException("平台默认配置不存在");
+
+        if (global.IsGroup)
+            throw new S8BizException("分组父节点由平台统一维护,不支持工厂覆盖");
+
+        var existing = await _rep.AsQueryable()
+            .Where(x => x.TypeCode == global.TypeCode
+                        && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .FirstAsync();
+        if (existing != null) return existing;
+
+        var copy = new AdoS8ExceptionType
+        {
+            Id = 0,
+            TenantId = scope.TenantId,
+            FactoryId = scope.FactoryId,
+            TypeCode = global.TypeCode,
+            TypeName = global.TypeName,
+            SceneCode = global.SceneCode,
+            SeverityDefault = global.SeverityDefault,
+            SlaMinutes = global.SlaMinutes,
+            OwnerRoleCode = global.OwnerRoleCode,
+            EscalateRoleCode = global.EscalateRoleCode,
+            StatsMode = global.StatsMode,
+            MonitoringCategoryKey = global.MonitoringCategoryKey,
+            MobileVisible = global.MobileVisible,
+            Icon = global.Icon,
+            Enabled = global.Enabled,
+            SortNo = global.SortNo,
+            // 父节点仍指向平台默认的分组父节点(分组不做工厂覆盖)。
+            ParentId = global.ParentId,
+            IsGroup = false,
+            Remark = global.Remark,
+            CreatedAt = DateTime.Now,
+            UpdatedAt = null,
+        };
+        copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
+        return copy;
+    }
+
+    /// <summary>
+    /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
+    /// 只删除当前工厂覆盖行,平台默认行不受影响;删除后读取自动回落平台默认。
+    /// </summary>
+    public async Task ResetToGlobalDefaultAsync(long overrideId, S8TrustedScope scope)
+    {
+        var e = await LoadScopedAsync(overrideId, scope);
+        await _rep.DeleteByIdAsync(e.Id);
+    }
+
     /// <summary>按 Id + 可信作用域取行;全局基线 (0/0) 与他租户行一律按「不存在」处理。</summary>
     /// <summary>按 Id + 可信作用域取行;全局基线 (0/0) 与他租户行一律按「不存在」处理。</summary>
     private async Task<AdoS8ExceptionType> LoadScopedAsync(long id, S8TrustedScope scope) =>
     private async Task<AdoS8ExceptionType> LoadScopedAsync(long id, S8TrustedScope scope) =>
         await _rep.AsQueryable()
         await _rep.AsQueryable()
@@ -192,6 +266,10 @@ public class S8ExceptionTypeService : ITransient
         Remark = e.Remark,
         Remark = e.Remark,
         CreatedAt = e.CreatedAt,
         CreatedAt = e.CreatedAt,
         UpdatedAt = e.UpdatedAt,
         UpdatedAt = e.UpdatedAt,
+        // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:作用域必须随树节点一起下发,
+        // 否则前端拿不到 scope,会把平台默认行误判为工厂行并渲染出最终 404 的写按钮。
+        Scope = S8ConfigScope.Resolve(e.TenantId, e.FactoryId),
+        HasGlobalDefault = e.HasGlobalDefault,
         Children = new List<AdoS8ExceptionTypeTreeNodeDto>(),
         Children = new List<AdoS8ExceptionTypeTreeNodeDto>(),
     };
     };
 }
 }

+ 172 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8KpiTargetConfigService.cs

@@ -0,0 +1,172 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S9 KPI 目标值配置服务(S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1)。
+///
+/// 复用 <c>ado_s8_monitor_metric</c>,不新建配置表;只维护 <c>default_target_ratio</c>(目标值)。
+/// 作用域语义与步骤 1 完全一致:平台默认(0/0)租户侧只读,需要调整走「自定义本工厂」覆盖,
+/// 「恢复平台默认」只删覆盖行。生效读取:工厂覆盖 &gt; 平台默认。
+///
+/// 边界:
+/// - 只暴露 Result KPI(mechanism='RATIO' AND is_result_kpi=1),当前 5 项;
+///   表里其余 8 行是监控指标字典,不属于业务目标值配置,不向业务前端暴露。
+/// - <b>只配置 TargetRatio。</b>KPI 当前值(CurrentValue)不是配置项,由业务计算/接口提供,本服务不涉及。
+/// </summary>
+public class S8KpiTargetConfigService : ITransient
+{
+    /// <summary>目标值为百分比,合法区间 [0, 100]。</summary>
+    public const decimal MinTargetRatio = 0m;
+    public const decimal MaxTargetRatio = 100m;
+
+    private readonly SqlSugarRepository<AdoS8MonitorMetric> _rep;
+
+    public S8KpiTargetConfigService(SqlSugarRepository<AdoS8MonitorMetric> rep) => _rep = rep;
+
+    /// <summary>Result KPI 判定:与 <see cref="S8MonitoringService.GetResultKpiSummaryAsync"/> 同口径。</summary>
+    private ISugarQueryable<AdoS8MonitorMetric> ResultKpiQuery() =>
+        _rep.AsQueryable().Where(x => x.Mechanism == "RATIO" && x.IsResultKpi);
+
+    /// <summary>
+    /// 列表:返回 5 项 Result KPI 的「生效目标值」视图。
+    /// 有工厂覆盖 → Scope=FACTORY 且 TargetRatio 取覆盖值;否则 Scope=GLOBAL 取平台默认值。
+    /// 不返回 TenantId / FactoryId / data_source 等技术字段。
+    /// </summary>
+    public async Task<List<AdoS8KpiTargetItemDto>> ListAsync(S8TrustedScope scope)
+    {
+        var rows = await ResultKpiQuery()
+            .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
+                        || (x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId))
+            .ToListAsync();
+
+        var globals = rows
+            .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
+            .ToDictionary(x => x.MetricCode);
+        var overrides = rows
+            .Where(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
+            .ToDictionary(x => x.MetricCode);
+
+        // 以平台默认行为骨架:平台没有定义的指标不应凭工厂覆盖凭空出现。
+        return globals.Values
+            .OrderBy(x => x.SortNo).ThenBy(x => x.MetricCode)
+            .Select(g =>
+            {
+                overrides.TryGetValue(g.MetricCode, out var ov);
+                return new AdoS8KpiTargetItemDto
+                {
+                    MetricCode = g.MetricCode,
+                    MetricName = g.MetricName,
+                    Unit = string.IsNullOrWhiteSpace(g.Unit) ? "%" : g.Unit!,
+                    TargetRatio = ov?.DefaultTargetRatio ?? g.DefaultTargetRatio,
+                    GlobalTargetRatio = g.DefaultTargetRatio,
+                    Remark = ov?.Remark ?? g.Remark,
+                    Scope = ov != null ? S8ConfigScope.Factory : S8ConfigScope.Global,
+                    HasFactoryOverride = ov != null,
+                };
+            })
+            .ToList();
+    }
+
+    /// <summary>
+    /// 自定义本工厂 / 编辑本工厂目标值(同一入口,按业务键 metricCode 定位,不暴露 DB Id)。
+    /// 无覆盖行 → 复制平台默认并盖章可信作用域后新建;已有覆盖行 → 就地更新目标值。
+    /// 归属列一律由服务端盖章,不接受客户端传入。
+    /// </summary>
+    public async Task<AdoS8KpiTargetItemDto> UpsertFactoryOverrideAsync(
+        string metricCode, decimal targetRatio, string? remark, S8TrustedScope scope)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode))
+            throw new S8BizException("指标编码必填");
+        ValidateTarget(targetRatio);
+
+        var code = metricCode.Trim();
+        var global = await ResultKpiQuery()
+            .Where(x => x.MetricCode == code
+                        && x.TenantId == S8ConfigScope.GlobalTenantId
+                        && x.FactoryId == S8ConfigScope.GlobalFactoryId)
+            .FirstAsync() ?? throw new S8NotFoundException("平台默认指标不存在");
+
+        var existing = await _rep.AsQueryable()
+            .Where(x => x.MetricCode == code
+                        && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .FirstAsync();
+
+        if (existing != null)
+        {
+            existing.DefaultTargetRatio = targetRatio;
+            if (remark != null) existing.Remark = NormalizeOrNull(remark);
+            existing.UpdatedAt = DateTime.Now;
+            await _rep.UpdateAsync(existing);
+        }
+        else
+        {
+            var copy = CloneForFactory(global, scope);
+            copy.DefaultTargetRatio = targetRatio;
+            if (remark != null) copy.Remark = NormalizeOrNull(remark);
+            copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
+        }
+
+        return (await ListAsync(scope)).First(x => x.MetricCode == code);
+    }
+
+    /// <summary>
+    /// 恢复平台默认:只删除当前工厂覆盖行,平台默认行不受影响;删除后读取自动回落平台默认。
+    /// 本就没有覆盖行时按「不存在」处理(幂等语义由调用方 404 表达)。
+    /// </summary>
+    public async Task<AdoS8KpiTargetItemDto> ResetToGlobalDefaultAsync(string metricCode, S8TrustedScope scope)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode))
+            throw new S8BizException("指标编码必填");
+        var code = metricCode.Trim();
+
+        var existing = await _rep.AsQueryable()
+            .Where(x => x.MetricCode == code
+                        && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .FirstAsync() ?? throw new S8NotFoundException("当前工厂没有覆盖配置,无需恢复");
+
+        await _rep.DeleteByIdAsync(existing.Id);
+        return (await ListAsync(scope)).First(x => x.MetricCode == code);
+    }
+
+    private static void ValidateTarget(decimal targetRatio)
+    {
+        if (targetRatio < MinTargetRatio || targetRatio > MaxTargetRatio)
+            throw new S8BizException($"目标值必须在 {MinTargetRatio:0}–{MaxTargetRatio:0} 之间");
+    }
+
+    private static string? NormalizeOrNull(string? v) =>
+        string.IsNullOrWhiteSpace(v) ? null : v.Trim();
+
+    /// <summary>复制平台默认行的字典字段,归属由服务端盖章;不复制 Id / 时间戳。</summary>
+    private static AdoS8MonitorMetric CloneForFactory(AdoS8MonitorMetric g, S8TrustedScope scope) => new()
+    {
+        Id = 0,
+        TenantId = scope.TenantId,
+        FactoryId = scope.FactoryId,
+        ObjectCode = g.ObjectCode,
+        MetricCode = g.MetricCode,
+        MetricName = g.MetricName,
+        Mechanism = g.Mechanism,
+        Unit = g.Unit,
+        DueAtField = g.DueAtField,
+        StatusField = g.StatusField,
+        MeasuredValueField = g.MeasuredValueField,
+        ObjectIdField = g.ObjectIdField,
+        ObjectCodeField = g.ObjectCodeField,
+        ObjectNameField = g.ObjectNameField,
+        DefaultGraceMinutes = g.DefaultGraceMinutes,
+        DefaultCompletedStates = g.DefaultCompletedStates,
+        DefaultTargetRatio = g.DefaultTargetRatio,
+        DefaultLowerBound = g.DefaultLowerBound,
+        DefaultUpperBound = g.DefaultUpperBound,
+        IsResultKpi = g.IsResultKpi,
+        Enabled = g.Enabled,
+        SortNo = g.SortNo,
+        Remark = g.Remark,
+        CreatedAt = DateTime.Now,
+        UpdatedAt = null,
+    };
+}

+ 23 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -815,7 +815,10 @@ public class S8MonitoringService : ITransient
                     ObjectCode = m.ObjectCode,
                     ObjectCode = m.ObjectCode,
                     Unit = unit,
                     Unit = unit,
                     CurrentValue = baseline.CurrentValue,
                     CurrentValue = baseline.CurrentValue,
-                    TargetRatio = baseline.TargetRatio,
+                    // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:目标值改为 DB 唯一事实源
+                    // (工厂覆盖 > 平台默认),不再由 _demoBaselineMap 覆盖;未配置时返回 null,
+                    // 前端显示「目标 待配置」,不做 C# 常量兜底。CurrentValue 维持原样,不属本批范围。
+                    TargetRatio = ResolveEffectiveTarget(m),
                     DictionaryEnabled = true,
                     DictionaryEnabled = true,
                     Remark = baseline.Remark,
                     Remark = baseline.Remark,
                     Source = "DEMO_BASELINE",
                     Source = "DEMO_BASELINE",
@@ -831,7 +834,8 @@ public class S8MonitoringService : ITransient
                     ObjectCode = m.ObjectCode,
                     ObjectCode = m.ObjectCode,
                     Unit = unit,
                     Unit = unit,
                     CurrentValue = cv,
                     CurrentValue = cv,
-                    TargetRatio = 90.0m,
+                    // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:原 90.0m 字面量改为 DB 生效目标值。
+                    TargetRatio = ResolveEffectiveTarget(m),
                     DictionaryEnabled = true,
                     DictionaryEnabled = true,
                     Remark = "基于已完成订单链路阶段 actual_days/planned_days 计算,pending 阶段暂不纳入",
                     Remark = "基于已完成订单链路阶段 actual_days/planned_days 计算,pending 阶段暂不纳入",
                     Source = source,
                     Source = source,
@@ -861,14 +865,26 @@ public class S8MonitoringService : ITransient
         };
         };
     }
     }
 
 
-    private static readonly Dictionary<string, (decimal CurrentValue, decimal TargetRatio, string Remark)> _demoBaselineMap = new()
+    /// <summary>
+    /// KPI 当前值演示基线。S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1 已把 TargetRatio 从本 map 解耦
+    /// (目标值改由 ado_s8_monitor_metric.default_target_ratio 承载);本 map 只保留 CurrentValue 与 Remark,
+    /// 其归属仍待业务拍板,本批不改数值、不删除、不做真实计算。
+    /// </summary>
+    private static readonly Dictionary<string, (decimal CurrentValue, string Remark)> _demoBaselineMap = new()
     {
     {
-        ["ORDER_DELIVERY_RATE"] = (92.0m, 95.0m, "演示基线,待真实订单交付口径接入"),
-        ["PO_DELIVERY_RATE"]    = (88.0m, 95.0m, "演示基线,待采购到货真实表接入"),
-        ["IQC_PASS_RATE"]       = (96.0m, 98.0m, "演示基线,待 IQC 检验真实表接入"),
-        ["WO_COMPLETION_RATE"]  = (90.0m, 95.0m, "演示基线,待工单完工真实表接入"),
+        ["ORDER_DELIVERY_RATE"] = (92.0m, "演示基线,待真实订单交付口径接入"),
+        ["PO_DELIVERY_RATE"]    = (88.0m, "演示基线,待采购到货真实表接入"),
+        ["IQC_PASS_RATE"]       = (96.0m, "演示基线,待 IQC 检验真实表接入"),
+        ["WO_COMPLETION_RATE"]  = (90.0m, "演示基线,待工单完工真实表接入"),
     };
     };
 
 
+    /// <summary>
+    /// 生效目标值:rows 已按 (0,0) 全局 ‖ (tenant,factory) 覆盖取回并按 FactoryId 降序去重,
+    /// 因此 <paramref name="m"/> 本身即生效行,直接取其 default_target_ratio。
+    /// 未配置返回 null —— 由 DB 作为唯一事实源,不回落任何 C# 常量。
+    /// </summary>
+    private static decimal? ResolveEffectiveTarget(AdoS8MonitorMetric m) => m.DefaultTargetRatio;
+
     /// <summary>
     /// <summary>
     /// 候选 A:actual_days IS NOT NULL 的已完成阶段中 actual_days <= planned_days 的占比。
     /// 候选 A:actual_days IS NOT NULL 的已完成阶段中 actual_days <= planned_days 的占比。
     /// pending 阶段不纳入 denominator;不依赖 status 字段;按 tenant/factory 过滤。
     /// pending 阶段不纳入 denominator;不依赖 status 字段;按 tenant/factory 过滤。