Procházet zdrojové kódy

fix(dataplatform): 岗位码人工映射加白名单校验 | server 1.0.586 / web 2.4.411

隐患:PUT /api/DataPlatform/position-maps 对 position_code 无任何校验,
code = row.PositionCode.Trim() 直接入库;前端「岗位码」列又是自由文本 el-input,
打错一个字就静默变成永远匹配不上 KPI 等值过滤(S3 PLANNER / S5、S7 WAREHOUSE /
S6 PRODUCTION)的死值,且因写入 role_source='MANUAL' 而不会被自动同步纠正。

- EmployeePositionRules 新增 AllowedCodes 白名单与 Normalize(),作为唯一数据源
- Normalize 忽略大小写但归一化为大写标准值:MySQL 默认排序规则不区分大小写,
  小写值不挡就会绕过校验混进库、却与等值过滤「看起来」仍匹配
- SavePositions 改为经 Normalize 校验,非法值抛 Oops.Oh 并回显合法取值
- 前端岗位码列改 el-select,与同页「库位角色」列已有写法一致
- 补 12 个用例:Normalize 边界、AllowedCodes 内容、保存路径确实接了校验、
  前端下拉选项与后端白名单一致(并钉住不得退回 el-input)

未改:SaveLocations 的 location_role 同样缺校验,属另一字段,本次不扩范围。

测试:3191 个用例,7 个失败均为改动前既有基线,零回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX před 2 týdny
rodič
revize
0f211d5c6c

+ 1 - 1
Web/package.json

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

+ 7 - 1
Web/src/views/aidop/data-platform/sourceMappings.vue

@@ -43,7 +43,11 @@
 					<el-table-column prop="sourceSystem" label="来源" width="160" />
 					<el-table-column prop="srcPositionRaw" label="源岗位" />
 					<el-table-column label="岗位码">
-						<template #default="{ row }"><el-input v-model="row.positionCode" /></template>
+						<template #default="{ row }">
+							<el-select v-model="row.positionCode" filterable>
+								<el-option v-for="c in positionCodes" :key="c" :label="c" :value="c" />
+							</el-select>
+						</template>
 					</el-table-column>
 				</el-table>
 			</el-tab-pane>
@@ -77,6 +81,8 @@ import service from '/@/utils/request';
 
 const tab = ref('domain');
 const roles = ['MAT_INSPECT', 'MATERIAL', 'MAT_LINE_LOC', 'SEMI', 'FG_INSPECT', 'FG', 'FG_LINE_LOC', 'REJECT_SCRAP', 'VMI', 'OTHER', 'UNKNOWN'];
+// 必须与后端 EmployeePositionRules.AllowedCodes 一致,由 EmployeePositionRulesTests 守卫。
+const positionCodes = ['WAREHOUSE', 'PRODUCTION', 'PLANNER', 'OTHER', 'UNKNOWN'];
 const tenantId = ref(localStorage.getItem('aidop-tenant-id') || '');
 const unknownOnly = ref(false);
 const domains = ref<any[]>([]);

+ 3 - 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.585</AssemblyVersion>
-    <FileVersion>1.0.585</FileVersion>
-    <Version>1.0.585</Version>
+    <AssemblyVersion>1.0.586</AssemblyVersion>
+    <FileVersion>1.0.586</FileVersion>
+    <Version>1.0.586</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 65 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/EmployeePositionRulesTests.cs

@@ -39,4 +39,69 @@ public class EmployeePositionRulesTests
     {
         Assert.Equal(expected, EmployeePositionRules.Classify(raw));
     }
+
+    [Theory]
+    [InlineData("WAREHOUSE", "WAREHOUSE")]
+    [InlineData("  PLANNER  ", "PLANNER")]
+    // MySQL 默认排序规则不区分大小写,小写值不挡住就会绕过校验混进库里,故统一归一化为大写标准值。
+    [InlineData("warehouse", "WAREHOUSE")]
+    [InlineData("Other", "OTHER")]
+    [InlineData("WAREHOUSE1", null)]
+    [InlineData("WAREHOUS", null)]
+    [InlineData("QUALITY", null)]
+    [InlineData("", null)]
+    [InlineData(null, null)]
+    public void Normalize_OnlyAcceptsAllowedCodes(string? input, string? expected)
+    {
+        Assert.Equal(expected, EmployeePositionRules.Normalize(input));
+    }
+
+    /// <summary>岗位码白名单只有这五个:Classify 的全部返回值 + 人工可选值,与 KPI 的等值过滤口径对齐。</summary>
+    [Fact]
+    public void AllowedCodes_AreExactlyTheFiveNeutralCodes()
+    {
+        Assert.Equal(
+            new[] { "WAREHOUSE", "PRODUCTION", "PLANNER", "OTHER", "UNKNOWN" },
+            EmployeePositionRules.AllowedCodes);
+    }
+
+    /// <summary>人工保存必须经过白名单,否则写错的岗位码会变成永远匹配不上 KPI 过滤的死值。</summary>
+    [Fact]
+    public void SavePositions_ValidatesAgainstWhitelist()
+    {
+        var service = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpSourceMappingService.cs"));
+
+        Assert.Contains("EmployeePositionRules.Normalize(row.PositionCode)", service);
+        Assert.Contains("EmployeePositionRules.AllowedCodes", service);
+        // 旧写法直接把前端传入值落库,不得复活。
+        Assert.DoesNotContain("code = row.PositionCode.Trim()", service);
+    }
+
+    /// <summary>前端下拉选项与后端白名单必须一致,否则页面能选出后端拒收的值。</summary>
+    [Fact]
+    public void FrontendPositionCodeOptions_MatchAllowedCodes()
+    {
+        var vue = File.ReadAllText(FindFile(
+            "Web", "src", "views", "aidop", "data-platform", "sourceMappings.vue"));
+
+        var expected = "const positionCodes = ["
+            + string.Join(", ", EmployeePositionRules.AllowedCodes.Select(code => $"'{code}'"))
+            + "];";
+        Assert.Contains(expected, vue);
+        // 岗位码不得退回自由文本输入。
+        Assert.DoesNotContain("<el-input v-model=\"row.positionCode\" />", vue);
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join('/', parts));
+    }
 }

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/EmployeePositionRules.cs

@@ -13,6 +13,9 @@ public static class EmployeePositionRules
     public const string Other = "OTHER";
     public const string Unknown = "UNKNOWN";
 
+    /// <summary>人工映射允许写入的岗位码全集。前端下拉选项必须与本数组一致,由守卫测试钉住。</summary>
+    public static readonly string[] AllowedCodes = [Warehouse, Production, Planner, Other, Unknown];
+
     public static readonly string[] WarehouseKeywords = ["仓", "库管", "保管", "物料员", "WMS", "warehouse", "storekeeper"];
     public static readonly string[] ProductionKeywords = ["生产", "操作", "装配", "车间", "班组", "产线", "工段", "production", "assembly", "operator", "workshop"];
     public static readonly string[] PlannerKeywords = ["计划", "PMC", "调度", "planner", "planning", "scheduler"];
@@ -47,6 +50,18 @@ public static class EmployeePositionRules
         return Unknown;
     }
 
+    /// <summary>
+    /// 规范化人工传入的岗位码:命中返回标准大写值,非法返回 null。
+    /// MySQL 默认排序规则不区分大小写,直接存小写会绕过校验却与 KPI 的等值过滤"看起来"仍匹配,故一律归一化再入库。
+    /// </summary>
+    public static string? Normalize(string? code)
+    {
+        if (string.IsNullOrWhiteSpace(code))
+            return null;
+        var text = code.Trim();
+        return AllowedCodes.FirstOrDefault(allowed => string.Equals(allowed, text, StringComparison.OrdinalIgnoreCase));
+    }
+
     // 岗位原文的大小写不由平台控制(165 的 JobTitle 是小写英文,T8 的 gw 是中文),统一忽略大小写比对。
     private static bool Matches(string text, string[] keywords)
         => keywords.Any(keyword => text.Contains(keyword, StringComparison.OrdinalIgnoreCase));

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs

@@ -155,6 +155,9 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
         {
             if (string.IsNullOrWhiteSpace(row.SourceSystem) || string.IsNullOrWhiteSpace(row.SrcPositionRaw) || string.IsNullOrWhiteSpace(row.PositionCode))
                 continue;
+            // 岗位码写错不会报错,只会变成永远匹配不上 KPI 等值过滤的死值,故在入库前挡掉并归一化大小写。
+            var code = EmployeePositionRules.Normalize(row.PositionCode)
+                ?? throw Oops.Oh($"岗位码 {row.PositionCode} 不合法,只能是 {string.Join(" / ", EmployeePositionRules.AllowedCodes)}");
             n += await _db.Ado.ExecuteCommandAsync(
                 """
                 INSERT INTO mdp_employee_position_map
@@ -169,7 +172,7 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
                     dom = row.Domain?.Trim() ?? "",
                     raw = row.SrcPositionRaw.Trim(),
                     field = string.IsNullOrWhiteSpace(row.SrcPositionField) ? "Position" : row.SrcPositionField.Trim(),
-                    code = row.PositionCode.Trim(),
+                    code,
                     remark = row.Remark
                 });
         }