Kaynağa Gözat

fix(s8): 让 KPI 停用开关真正生效,并纠正操作员绑定页的误导标题

配置权威性修复(两项,均为「页面看得到但改了不生效」):

1) 结果 KPI 的 enabled 门禁
   GetResultKpiSummaryAsync 此前只按 mechanism / is_result_kpi / 租户作用域取行,
   完全不读 enabled。运行库 5 条结果 KPI 全部 enabled=0(remark "[RESERVED]"),
   却照常出现在监控总览——配置的停用开关对业务零效力。
   门禁刻意放在**覆盖解析之后**:若下推成 SQL 的 WHERE,租户停用某指标时该行会先被
   滤掉,分组里只剩 enabled=true 的平台全局行,结果反而回落成"显示",与停用意图相反。
   为此把「覆盖解析 → 启用门禁」抽为 internal static,便于契约测试直接驱动。
   影响面:唯一消费者是监控总览的「S9 运营指标 / 结果 KPI」Tab;QDC 四桶恒非空,
   卡片本体存活,只是 KPI 条目变为占位文案。

2) /aidop/s8/config/roles 的标题
   该路由的旧版角色权限 Tab 已在 S8-CONFIG-CLEANUP-DEMO-1 隐藏,页面现在只承载
   S8OperatorBindingTab(EmployeeMaster.SysUserId ↔ SysUser 的身份映射),且它是该功能
   的唯一入口。S8 真实鉴权走 SysMenu(Type=Btn).Permission → SysRoleMenu →
   GetOwnBtnPermList,与 ado_s8_role_permission_config 无关(该表运行期零消费)。
   继续叫「角色权限配置」会让用户以为能在这里改权限,故只纠正三处标题:
   页面 H2 / 配置中心卡片 / SysMenu.Title(种子 + 1.0.482.sql 幂等 UPDATE 双落)。
   Name / Path / Component 一律不动,避免动态路由与既有授权断链。
   不下线该入口——下线会让操作员账号绑定彻底不可达。

ado_s8_role_permission_config / S8RoleConfigService / AdoS8ConfigRolesController
已证实为 DEAD_DUPLICATION,但退役放后续独立批次,本批不删。

chore: bump version server 1.0.482 / Web 2.4.375
YY968XX 14 saat önce
ebeveyn
işleme
bc9cb3f15f

+ 1 - 1
Web/package.json

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

+ 4 - 1
Web/src/views/aidop/s8/config/S8ConfigHubPage.vue

@@ -33,7 +33,10 @@ const cards = [
 	{ 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: '操作员与员工的绑定维护' },
+	// S8-CONFIG-AUTHORITY-FIX-1:该页只承载操作员账号绑定(EmployeeMaster.SysUserId ↔ SysUser),
+	// 旧版角色权限 Tab 已在 S8-CONFIG-CLEANUP-DEMO-1 隐藏;S8 正式鉴权走 SysMenu/SysRoleMenu,
+	// 与本页无关。标题保留「角色权限」会让用户以为在这里能改权限,故去掉。
+	{ path: '/aidop/s8/config/roles', title: '操作员账号绑定', desc: '操作员与员工的绑定维护' },
 	{ path: '/aidop/s8/config/data-sources', title: '数据源', desc: '连接与启用' },
 	{ path: '/aidop/s8/config/notifications', title: '通知分层', desc: '场景 + 严重度 + 层级 + 推送渠道' },
 	{ path: '/aidop/s8/config/alert-rules', title: '报警规则', desc: '历史兼容只读,运行时判定见监控规则' },

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

@@ -49,8 +49,8 @@ async function syncFromSys(reload: () => void) {
 <template>
 	<div class="s8-role-config-page">
 		<div class="page-header">
-			<h2>角色权限配置</h2>
-			<div class="subtitle">S8 / 配置 / 角色权限</div>
+			<h2>操作员账号绑定</h2>
+			<div class="subtitle">S8 / 配置 / 操作员账号绑定</div>
 		</div>
 		<!--
 			S8-CONFIG-CLEANUP-DEMO-1:仅展示操作员账号绑定,隐藏旧版角色权限 Tab。

+ 182 - 0
server/Admin.NET.Test/S8/S8ResultKpiEnabledGateTests.cs

@@ -0,0 +1,182 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-CONFIG-AUTHORITY-FIX-1:结果 KPI 的「停用即不显示」门禁。
+///
+/// 修复前的缺陷:<c>GetResultKpiSummaryAsync</c> 只按 mechanism / is_result_kpi / 租户作用域取行,
+/// <b>完全不读 enabled</b>。运行库里 5 条结果 KPI 全部 enabled=0(remark "[RESERVED] S9 result KPI"),
+/// 却照常出现在监控总览——配置页面的停用开关对业务零效力。
+///
+/// 本文件锁定的契约与失败后果:
+///   A. enabled=true  → 纳入          → 否则正常指标消失,属功能倒退
+///   B. enabled=false → 不纳入        → 否则"停用"仍然显示,用户认为配置坏了
+///   C. is_result_kpi=false → 不纳入  → 否则机制字典项(Rule 向导用)会污染结果 KPI 卡片
+///   D. 门禁必须在**覆盖解析之后**   → 这是最容易写错的一条:
+///      若把 Enabled 下推成 SQL WHERE,租户 override enabled=false 的行会先被滤掉,
+///      分组里只剩 enabled=true 的全局行 → 结果回落成"显示",与停用意图完全相反。
+///   E. 不触碰 Rule 机制字典          → 门禁只作用于结果 KPI 路径
+///
+/// 不接 DB、不接 DI、不触发任何真实规则。
+/// </summary>
+public class S8ResultKpiEnabledGateTests
+{
+    private const long TenantId = 838257186181189L;
+    private const long FactoryId = 838257186320453L;
+
+    private static AdoS8MonitorMetric Metric(
+        string code, bool enabled, bool isResultKpi = true,
+        long tenantId = 0, long factoryId = 0, string mechanism = "RATIO", int sortNo = 0) => new()
+    {
+        MetricCode = code,
+        MetricName = code,
+        Mechanism = mechanism,
+        IsResultKpi = isResultKpi,
+        Enabled = enabled,
+        TenantId = tenantId,
+        FactoryId = factoryId,
+        SortNo = sortNo
+    };
+
+    private static List<string> CodesOf(IEnumerable<AdoS8MonitorMetric> rows) =>
+        rows.Select(x => x.MetricCode).ToList();
+
+    // ============================================================
+    // A/B/C. 基础门禁
+    // ============================================================
+
+    [Fact]
+    public void T1_EnabledResultKpi_IsReturned()
+    {
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("ORDER_DELIVERY_RATE", enabled: true)
+        });
+
+        Assert.Equal(new[] { "ORDER_DELIVERY_RATE" }, CodesOf(result));
+    }
+
+    [Fact]
+    public void T2_DisabledResultKpi_IsNotReturned()
+    {
+        // 运行库现状:5 条结果 KPI 全部 enabled=0,修复前它们照常显示。
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("IQC_PASS_RATE", enabled: false)
+        });
+
+        Assert.Empty(result);
+    }
+
+    [Fact]
+    public void T3_NonResultKpi_IsNotReturned()
+    {
+        // is_result_kpi=false 的是 Rule 向导用的机制字典项(DATE / VALUE_RANGE),
+        // 即使 enabled=true 也不属于结果 KPI 卡片。
+        // 注:调用方 SQL 已按 IsResultKpi 过滤,这里再断言一次,防止将来有人把过滤挪进本方法时写反。
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("ORDER_DUE_AT", enabled: true, isResultKpi: false, mechanism: "DATE")
+        }.Where(m => m.IsResultKpi));
+
+        Assert.Empty(result);
+    }
+
+    // ============================================================
+    // D. 覆盖解析与门禁的先后顺序 —— 本文件最关键的两条
+    // ============================================================
+
+    [Fact]
+    public void T4_TenantOverrideDisable_HidesMetric_EvenWhenGlobalEnabled()
+    {
+        // 全局启用 + 租户停用 → 租户看不到。
+        // 若实现把 Enabled 下推成 SQL WHERE,租户行会先被滤掉、只剩全局行,
+        // 结果反而变成"可见"——本用例正是为了钉死这个反向缺陷。
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("ORDER_DELIVERY_RATE", enabled: true),                                        // 平台全局
+            Metric("ORDER_DELIVERY_RATE", enabled: false, tenantId: TenantId, factoryId: FactoryId) // 租户覆盖:停用
+        });
+
+        Assert.Empty(result);
+    }
+
+    [Fact]
+    public void T5_TenantOverrideEnable_ShowsMetric_EvenWhenGlobalDisabled()
+    {
+        // 反向:全局停用 + 租户启用 → 租户可见。
+        // 覆盖语义由 OrderByDescending(FactoryId).ThenByDescending(TenantId) 承载,本用例确认它对两个方向都成立。
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("PO_DELIVERY_RATE", enabled: false),
+            Metric("PO_DELIVERY_RATE", enabled: true, tenantId: TenantId, factoryId: FactoryId)
+        });
+
+        Assert.Equal(new[] { "PO_DELIVERY_RATE" }, CodesOf(result));
+    }
+
+    [Fact]
+    public void T5b_EachMetricCode_ResolvesToExactlyOneEffectiveRow()
+    {
+        // 覆盖解析必须"每个 code 只留一条",否则同一 KPI 会在卡片上重复出现。
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("WO_COMPLETION_RATE", enabled: true),
+            Metric("WO_COMPLETION_RATE", enabled: true, tenantId: TenantId),
+            Metric("WO_COMPLETION_RATE", enabled: true, tenantId: TenantId, factoryId: FactoryId)
+        });
+
+        Assert.Single(result);
+        // 工厂行优先级最高。
+        Assert.Equal(FactoryId, result[0].FactoryId);
+    }
+
+    // ============================================================
+    // E. Rule 机制字典不受影响
+    // ============================================================
+
+    [Fact]
+    public void T6_RuleMechanismDictionary_IsUntouchedByTheGate()
+    {
+        // 运行库中 ado_s8_monitor_metric 有 8 条 enabled=1 的机制字典项(DATE / VALUE_RANGE),
+        // 由 S8ConfigDraftService(Rule 向导)消费。本门禁只在结果 KPI 路径调用,
+        // 这里断言:即使把它们混进候选集,也只会因 IsResultKpi=false 被调用方排除,
+        // 而不会被本方法当作结果 KPI 返回。
+        var mixed = new[]
+        {
+            Metric("ORDER_DUE_AT",              enabled: true, isResultKpi: false, mechanism: "DATE"),
+            Metric("INV_QTY",                   enabled: true, isResultKpi: false, mechanism: "VALUE_RANGE"),
+            Metric("ORDER_FLOW_STAGE_VARIANCE", enabled: true, isResultKpi: false, mechanism: "VALUE_RANGE"),
+            Metric("ORDER_DELIVERY_RATE",       enabled: true)
+        };
+
+        // 模拟调用方 SQL 的 mechanism + is_result_kpi 过滤。
+        var candidates = mixed.Where(m => m.Mechanism == "RATIO" && m.IsResultKpi);
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(candidates);
+
+        Assert.Equal(new[] { "ORDER_DELIVERY_RATE" }, CodesOf(result));
+        // 机制字典项全部保持 enabled=true,未被本批改动。
+        Assert.All(mixed.Where(m => !m.IsResultKpi), m => Assert.True(m.Enabled));
+    }
+
+    // ============================================================
+    // 排序稳定性(卡片顺序不应因门禁而乱)
+    // ============================================================
+
+    [Fact]
+    public void EnabledMetrics_KeepSortNoThenCodeOrdering()
+    {
+        var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
+        {
+            Metric("B_RATE", enabled: true,  sortNo: 2),
+            Metric("A_RATE", enabled: true,  sortNo: 2),
+            Metric("Z_RATE", enabled: true,  sortNo: 1),
+            Metric("X_RATE", enabled: false, sortNo: 0)   // 停用项不参与排序结果
+        });
+
+        Assert.Equal(new[] { "Z_RATE", "A_RATE", "B_RATE" }, CodesOf(result));
+    }
+}

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

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

+ 19 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.482.sql

@@ -0,0 +1,19 @@
+-- S8-CONFIG-AUTHORITY-FIX-1
+-- 纠正 /aidop/s8/config/roles 的菜单标题。
+--
+-- 背景:该路由的旧版「角色权限」Tab 已在 S8-CONFIG-CLEANUP-DEMO-1 隐藏,页面现在只承载
+-- S8OperatorBindingTab(EmployeeMaster.SysUserId ↔ SysUser 的身份映射)。
+-- S8 的真实鉴权走 SysMenu(Type=Btn).Permission → SysRoleMenu → SysMenuService.GetOwnBtnPermList(),
+-- 与 ado_s8_role_permission_config 无关(该表运行期零消费,退役放后续独立批次)。
+-- 标题继续叫「角色权限配置」会让用户以为能在这里改权限,故仅纠正 Title。
+--
+-- 只改 Title:Name / Path / Component / Pid / Type / Status / IsHide 一律不动,
+-- 避免动态路由失效或既有 SysRoleMenu 授权断链。
+-- 幂等:按 Name 精确定位,重复执行不会产生额外影响。
+
+UPDATE SysMenu
+   SET Title = '操作员账号绑定',
+       UpdateTime = NOW()
+ WHERE Name = 'aidopS8RolePermissionConfig'
+   AND Path = '/aidop/s8/config/roles'
+   AND Title <> '操作员账号绑定';

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -1255,7 +1255,9 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         {
             (11, "/aidop/s8/config/scenes", "aidopS8ScenarioConfig", "场景基础配置", "/aidop/s8/config/S8ScenarioConfigPage"),
             (12, "/aidop/s8/config/notifications", "aidopS8NotificationLayerConfig", "通知分层配置", "/aidop/s8/config/S8NotificationLayerPage"),
-            (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "角色权限配置", "/aidop/s8/config/S8RolePermissionConfigPage"),
+            // S8-CONFIG-AUTHORITY-FIX-1:该路由只承载操作员账号绑定;S8 鉴权走 SysMenu/SysRoleMenu,
+            // 与本页无关。Name / Path / Component 保持不变(避免动态路由与既有授权失效),仅纠正 Title。
+            (13, "/aidop/s8/config/roles", "aidopS8RolePermissionConfig", "操作员账号绑定", "/aidop/s8/config/S8RolePermissionConfigPage"),
             (14, "/aidop/s8/config/alert-rules", "aidopS8AlertRulesConfig", "报警规则配置", "/aidop/s8/config/S8AlertRulesPage"),
             (15, "/aidop/s8/config/data-sources", "aidopS8DataSourceConfig", "数据源配置", "/aidop/s8/config/S8DataSourceConfigPage"),
             (16, "/aidop/s8/config/watch-rules", "aidopS8WatchRuleConfig", "监视规则配置", "/aidop/s8/config/S8WatchRuleConfigPage"),

+ 22 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -798,6 +798,27 @@ public class S8MonitoringService : ITransient
     /// 不写 DB / 不接 period / 不启用 watch_rule。
     /// 来源:ado_s8_monitor_metric WHERE mechanism='RATIO' AND is_result_kpi=1(保留字典顺序与 metricName)。
     /// </summary>
+    /// <summary>
+    /// S8-CONFIG-AUTHORITY-FIX-1:结果 KPI 的「覆盖解析 → 启用门禁」。
+    ///
+    /// <b>为什么门禁必须在覆盖解析之后</b>:候选行同时含平台全局行(tenant/factory=0)与租户覆盖行。
+    /// 若把 <c>Enabled</c> 下推成 SQL 的 WHERE,租户停用某指标(override enabled=false)时该行会先被滤掉,
+    /// 分组里只剩 enabled=true 的全局行,结果反而回落成「显示」——与用户的停用意图完全相反。
+    /// 因此:先按 工厂行 &gt; 租户行 &gt; 全局行 定出唯一 EffectiveMetric,再判它的 Enabled。
+    ///
+    /// 本方法只做纳入与否的门禁,<b>不参与任何 KPI 数值计算</b>;
+    /// 也只作用于结果 KPI 路径,不影响 Rule 向导消费的机制字典(mechanism = DATE / VALUE_RANGE)。
+    /// internal 暴露供契约测试直接驱动(沿用 evaluator EvaluateRows 的既有范式)。
+    /// </summary>
+    internal static List<AdoS8MonitorMetric> ResolveEnabledEffectiveResultKpis(IEnumerable<AdoS8MonitorMetric> candidateRows) =>
+        candidateRows
+            .GroupBy(x => x.MetricCode)
+            .Select(g => g.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).First())
+            .Where(m => m.Enabled)
+            .OrderBy(x => x.SortNo)
+            .ThenBy(x => x.MetricCode)
+            .ToList();
+
     public async Task<AdoS8ResultKpiSummaryDto> GetResultKpiSummaryAsync(long tenantId, long factoryId)
     {
         var rows = await _metricRep.AsQueryable()
@@ -806,12 +827,7 @@ public class S8MonitoringService : ITransient
                     || (x.TenantId == tenantId && x.FactoryId == factoryId)))
             .ToListAsync();
 
-        var resolved = rows
-            .GroupBy(x => x.MetricCode)
-            .Select(g => g.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).First())
-            .OrderBy(x => x.SortNo)
-            .ThenBy(x => x.MetricCode)
-            .ToList();
+        var resolved = ResolveEnabledEffectiveResultKpis(rows);
 
         var items = new List<AdoS8ResultKpiItemDto>(resolved.Count);
         foreach (var m in resolved)