Explorar o código

fix(s0): bind line post skill by business code and preserve legacy detail keys

产线岗位(line-post)技能选择器与明细写路径收口。两个缺陷,同一条写链路。

【缺陷 1】技能选择器绑定了错误的字段
person-skills/options 的 value 是 PersonSkill.Code("102"),payload 里没有内部 Id;
上一批却按 PersonSkill.Id 去匹配,导致无选项命中、el-select 回退显示原始模型值:
  Edit   显示 54(LineSkillDetail.PersonSkillId 的裸内部主键)
  Create 显示 0(newSkill 的占位值)

修法采用「UI 用业务编码 + 服务端解析」,**不**把内部主键暴露成前端 select 值,
options 端点契约保持不变(value=Code / label=Code / Name):
  - Upsert DTO: PersonSkillId(long) -> PersonSkillCode(string, Required)
  - 新增 ResolvePersonSkillCodesAsync:按当前登录租户解析 Code -> Id
      0 命中   -> 400「不存在或不属于当前作用域」
      1 命中   -> 落库
      >1 命中  -> 400 要求先处理主数据重码(不做 First() 兜底)
    作用域依据实测的 uk_PersonSkill_code = (tenant_id, CompanyRefId, FactoryRefId, Code);
    PersonSkill 表**没有 Domain 列**,故不按 Domain 收窄。
  - GetDetail 批量补显 personSkillCode / personSkillName(单次查询 + 字典映射,无 N+1)

【缺陷 2】Update 的「删除旧明细 + 重插」丢失全部遗留业务键
AdoS0LineSkillDetail 从未映射 Domain / Site / ProdLine / JOBNo / SkillNo,
重插时这 5 列被写成 NULL —— 一次 no-op 保存就会把明细的业务键抹掉。
(该缺陷为既有问题,本次做写路径冒烟时暴露,已连同修复。)
补映射这 5 列,并在 MapSkill 中由主表与已解析的技能编码回填。

⚠️ SkillNo 与 PersonSkillId 语义不同,不得互相顶替:
   SkillNo       = 技能业务编码快照(字符串,来自 PersonSkill.Code)
   PersonSkillId = 指向 PersonSkill 主数据的内部主键(bigint FK)
   当前数据两者都显示 "102" 属编码巧合,PersonSkill.Code='102' 的 Id 实为 54。

租户安全:Update / Delete 均先 ByIdScopedAsync 校验主表归属,
明细删除再显式带 TenantId 双重收窄;技能解析全程 ScopedTo(当前租户)。

实测闭环:UI "102" -> 服务端解析 -> DB PersonSkillId=54 -> 回显 "102 / 工中核心";
目标租户 11 master / 11 detail 的遗留业务键全部健康,Site 保持 NULL。
不含 migration,DB migration max 仍为 1.0.412。

chore: bump version server 1.0.413 / Web 2.4.334
YY968XX hai 1 día
pai
achega
be5f166750

+ 1 - 1
Web/package.json

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

+ 10 - 1
Web/src/views/aidop/s0/api/s0ManufacturingApi.ts

@@ -196,7 +196,15 @@ export interface S0MfgLinePostHeader {
 export interface S0MfgLinePostSkillRow {
 	id: number;
 	lineSkillMasterId: number;
+	/**
+	 * 人员技能内部主键(PersonSkill.Id)。只读回显字段,
+	 * **禁止**作为技能选择器的业务值 —— UI 一律用 personSkillCode。
+	 */
 	personSkillId: number;
+	/** 人员技能业务编码(PersonSkill.Code),技能选择器的唯一合法 v-model 值。 */
+	personSkillCode?: string | null;
+	/** 人员技能名称(PersonSkill.Name),仅展示。 */
+	personSkillName?: string | null;
 	requiredLevel?: string | null;
 	remark?: string | null;
 	effectiveDate?: string | null;
@@ -209,7 +217,8 @@ export interface S0MfgLinePostDetail {
 }
 
 export interface S0MfgLinePostSkillUpsert {
-	personSkillId: number;
+	/** 提交的是业务编码;服务端按当前租户解析成 PersonSkill.Id 后落库。 */
+	personSkillCode: string;
 	requiredLevel?: string;
 	remark?: string;
 	effectiveDate?: string | null;

+ 1 - 10
Web/src/views/aidop/s0/composables/useS0BusinessLookups.ts

@@ -19,7 +19,7 @@ export interface S0LookupOption {
 	label: string;
 }
 
-export type S0LookupKey = 'item' | 'line' | 'personSkill' | 'workCenter' | 'location' | 'employee' | 'customer' | 'supplier' | 'party' | 'barcodeType' | 'personSkillId';
+export type S0LookupKey = 'item' | 'line' | 'personSkill' | 'workCenter' | 'location' | 'employee' | 'customer' | 'supplier' | 'party' | 'barcodeType';
 
 /** 下拉一次性取数上限:S0 主数据为配置级数据量,单页取全量即可,避免分页拼装。 */
 const PAGE_SIZE = 500;
@@ -75,15 +75,6 @@ const loaders: Record<S0LookupKey, () => Promise<S0LookupOption[]>> = {
 		const d = await s0SuppliersApi.list({ page: 1, pageSize: PAGE_SIZE });
 		return dedupe((d.list ?? []).map((x: any) => norm(x.supp, x.sortName)));
 	},
-	/**
-	 * 人员技能 —— 以【内部主键 Id】为 value。
-	 * LineSkillDetail.PersonSkillId 存的就是 PersonSkill 的自增主键(schema 决定,非本批可改),
-	 * 因此这里 value 必须是 Id;但用户只按「编码 / 名称」选择,绝不手输内部 Id。
-	 */
-	personSkillId: async () => {
-		const d = await s0MfgPersonSkillsApi.options();
-		return dedupe((d ?? []).map((x: any) => norm(x.value, [x.code, x.name].filter(Boolean).join(' / '))));
-	},
 	/**
 	 * 客户 + 供应商合并主体。
 	 * BarCodeNbr.Customer 是 legacy 字段(列描述即「客户/供应商编码」),同一字段两种主体共用,

+ 11 - 8
Web/src/views/aidop/s0/manufacturing/LinePostList.vue

@@ -57,8 +57,8 @@
 			</div>
 			<el-table :data="form.skills" border stripe style="width: 100%" row-key="__k" @selection-change="onSelectionChange">
 				<el-table-column type="selection" width="45" />
-				<el-table-column label="人员技能Id" width="140">
-					<template #default="{ row }"><el-select v-model="row.personSkillId" filterable clearable placeholder="请选择技能" style="width: 100%"><el-option v-for="o in lookup.personSkillId" :key="o.value" :label="o.label" :value="o.value" /></el-select></template>
+				<el-table-column label="人员技能" width="200">
+					<template #default="{ row }"><el-select v-model="row.personSkillCode" filterable clearable placeholder="请选择技能" style="width: 100%"><el-option v-for="o in lookup.personSkill" :key="o.value" :label="o.label" :value="o.value" /></el-select></template>
 				</el-table-column>
 				<el-table-column label="要求等级" width="120">
 					<template #default="{ row }"><el-input v-model="row.requiredLevel" /></template>
@@ -87,11 +87,13 @@
 <script setup lang="ts" name="aidopS0MfgLinePost">
 import { reactive as __reactive_lk } from 'vue';
 import { loadS0Lookup, type S0LookupOption } from '../composables/useS0BusinessLookups';
-// 生产线按业务编码(LineMaster.Line)选择;技能按编码/名称选择但绑定 PersonSkill 内部主键
-//(LineSkillDetail.PersonSkillId 的 schema 要求),用户不再手输内部 Id。
-const lookup = __reactive_lk<Record<string, S0LookupOption[]>>({ line: [], personSkillId: [] });
+// 生产线与人员技能一律以【业务编码】作为 select 值:
+//   生产线 -> LineMaster.Line ;人员技能 -> PersonSkill.Code
+// 内部主键(PersonSkill.Id)不进入前端业务模型 —— 保存时由服务端按当前租户把
+// Code 解析成 Id 再落库。故此处只用 string,禁止 Number()/parseInt 转换。
+const lookup = __reactive_lk<Record<string, S0LookupOption[]>>({ line: [], personSkill: [] });
 void loadS0Lookup('line').then((r) => (lookup.line = r));
-void loadS0Lookup('personSkillId').then((r) => (lookup.personSkillId = r));
+void loadS0Lookup('personSkill').then((r) => (lookup.personSkill = r));
 
 import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
@@ -124,7 +126,8 @@ const formRef = ref<FormInstance>();
 function newSkill() {
 	return {
 		__k: `${Date.now()}_${Math.random()}`,
-		personSkillId: 0,
+		// 初值必须为空串:0 / "0" 都不是合法技能编码,会让 select 显示裸值
+		personSkillCode: '',
 		requiredLevel: '',
 		remark: '',
 		effectiveDate: undefined as string | undefined,
@@ -241,7 +244,7 @@ async function submitForm() {
 		runCrew: cache?.runCrew ?? undefined,
 		updateUser: cache?.updateUser ?? undefined,
 		skills: (form.skills || []).map((x: any) => ({
-			personSkillId: x.personSkillId,
+			personSkillCode: x.personSkillCode ?? '',
 			requiredLevel: x.requiredLevel || undefined,
 			remark: x.remark || undefined,
 			effectiveDate: x.effectiveDate ?? null,

+ 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.412</AssemblyVersion>
-    <FileVersion>1.0.412</FileVersion>
-    <Version>1.0.412</Version>
+    <AssemblyVersion>1.0.413</AssemblyVersion>
+    <FileVersion>1.0.413</FileVersion>
+    <Version>1.0.413</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 82 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgLinePostsController.cs

@@ -89,6 +89,9 @@ public class AdoS0MfgLinePostsController : ControllerBase
             .Where(x => x.LineSkillMasterId == id)
             .OrderBy(x => x.Id)
             .ToListAsync();
+        // 补显人员技能的业务编码/名称:一次批量查询 + 字典映射,杜绝 N+1;
+        // 显式限定当前租户,不跨租户取名。
+        await FillPersonSkillDisplayAsync(skills, tenantId);
         return Ok(new { header, linePostSkills = skills });
     }
 
@@ -96,7 +99,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LineSkillMasterUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        var fkErr = await ValidatePersonSkillsAsync(dto, tenantId);
+        var (fkErr, skillMap) = await ResolvePersonSkillCodesAsync(dto, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
         var db = _postRep.Context;
@@ -124,7 +127,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
 
             foreach (var s in dto.Skills)
             {
-                var row = MapSkill(post.Id, s, tenantId);
+                var row = MapSkill(post, s, tenantId, skillMap);
                 await _skillRep.AsInsertable(row).ExecuteCommandAsync();
             }
 
@@ -145,7 +148,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
         var header = await _postRep.ByIdScopedAsync(id, tenantId);
         if (header == null) return NotFound();
 
-        var fkErr = await ValidatePersonSkillsAsync(dto, tenantId);
+        var (fkErr, skillMap) = await ResolvePersonSkillCodesAsync(dto, tenantId);
         if (fkErr != null) return BadRequest(new { message = fkErr });
 
         var db = _postRep.Context;
@@ -164,10 +167,10 @@ public class AdoS0MfgLinePostsController : ControllerBase
             header.UpdateTime = dto.UpdateTime ?? DateTime.Now;
             await _postRep.AsUpdateable(header).ExecuteCommandAsync();
 
-            await _skillRep.AsDeleteable().Where(x => x.LineSkillMasterId == id).ExecuteCommandAsync();
+            await _skillRep.AsDeleteable().Where(x => x.LineSkillMasterId == id && x.TenantId == tenantId).ExecuteCommandAsync();
             foreach (var s in dto.Skills)
             {
-                var row = MapSkill(id, s, tenantId);
+                var row = MapSkill(header, s, tenantId, skillMap);
                 await _skillRep.AsInsertable(row).ExecuteCommandAsync();
             }
 
@@ -192,7 +195,7 @@ public class AdoS0MfgLinePostsController : ControllerBase
         await db.Ado.BeginTranAsync();
         try
         {
-            await _skillRep.AsDeleteable().Where(x => x.LineSkillMasterId == id).ExecuteCommandAsync();
+            await _skillRep.AsDeleteable().Where(x => x.LineSkillMasterId == id && x.TenantId == tenantId).ExecuteCommandAsync();
             await _postRep.DeleteAsync(header);
             await db.Ado.CommitTranAsync();
             return Ok(new { message = "删除成功" });
@@ -204,25 +207,89 @@ public class AdoS0MfgLinePostsController : ControllerBase
         }
     }
 
-    private async Task<string?> ValidatePersonSkillsAsync(AdoS0LineSkillMasterUpsertDto dto, long tenantId)
+    /// <summary>
+    /// 把提交的人员技能【业务编码】解析为当前租户下的 PersonSkill.Id。
+    ///
+    /// 作用域依据:uk_PersonSkill_code = UNIQUE (tenant_id, CompanyRefId, FactoryRefId, Code)。
+    /// PersonSkill 表**没有 Domain 列**,故不按 Domain 收窄;产线岗位主表也不带
+    /// CompanyRefId / FactoryRefId,无法据此再收窄,因此按 (租户, Code) 解析并对
+    /// 多义(同租户下跨公司/工厂重码)显式报错,而不是随便取一条。
+    ///
+    /// 返回 error 时不写库;成功时返回 code -&gt; id 映射。
+    /// </summary>
+    private async Task<(string? Error, Dictionary<string, long> Map)> ResolvePersonSkillCodesAsync(
+        AdoS0LineSkillMasterUpsertDto dto, long tenantId)
     {
+        var map = new Dictionary<string, long>(StringComparer.Ordinal);
         if (dto.Skills == null || dto.Skills.Count == 0)
-            return null;
+            return (null, map);
 
-        var ids = dto.Skills.Select(s => s.PersonSkillId).Distinct().ToList();
-        var cnt = await _personSkillRep.ScopedTo(tenantId)
+        var codes = dto.Skills
+            .Select(s => (s.PersonSkillCode ?? string.Empty).Trim())
+            .Where(c => c.Length > 0)
+            .Distinct(StringComparer.Ordinal)
+            .ToList();
+
+        if (codes.Count == 0)
+            return ("人员技能不能为空", map);
+
+        var rows = await _personSkillRep.ScopedTo(tenantId)
+            .Where(p => codes.Contains(p.Code))
+            .Select(p => new { p.Id, p.Code })
+            .ToListAsync();
+
+        foreach (var code in codes)
+        {
+            var hits = rows.Where(r => string.Equals(r.Code, code, StringComparison.Ordinal)).ToList();
+            if (hits.Count == 0)
+                return ($"人员技能 {code} 不存在或不属于当前作用域", map);
+            if (hits.Count > 1)
+                return ($"人员技能编码 {code} 在当前租户下存在 {hits.Count} 条同码主数据,无法唯一确定,请先处理主数据重码", map);
+            map[code] = hits[0].Id;
+        }
+
+        return (null, map);
+    }
+
+    /// <summary>批量补显 PersonSkill 的 Code / Name(单次查询 + 字典映射,无 N+1)。</summary>
+    private async Task FillPersonSkillDisplayAsync(List<AdoS0LineSkillDetail> rows, long tenantId)
+    {
+        if (rows.Count == 0) return;
+        var ids = rows.Select(r => r.PersonSkillId).Distinct().ToList();
+        var skills = await _personSkillRep.ScopedTo(tenantId)
             .Where(p => ids.Contains(p.Id))
-            .CountAsync();
-        return cnt == ids.Count ? null : "存在无效的人员技能主数据引用";
+            .Select(p => new { p.Id, p.Code, p.Name })
+            .ToListAsync();
+        var map = skills.GroupBy(x => x.Id).ToDictionary(g => g.Key, g => g.First());
+        foreach (var r in rows)
+        {
+            if (map.TryGetValue(r.PersonSkillId, out var p))
+            {
+                r.PersonSkillCode = p.Code;
+                r.PersonSkillName = p.Name;
+            }
+        }
     }
 
-    private static AdoS0LineSkillDetail MapSkill(long lineSkillMasterId, AdoS0LineSkillDetailUpsertDto s, long tenantId)
+    /// <summary>
+    /// 明细行映射。除新体系字段外,同时回填旧系统遗留业务键
+    /// (Domain / Site / ProdLine / JOBNo 取自主表,SkillNo 取自提交的技能业务编码),
+    /// 避免 Update 的「删除+重插」把这些列写成 NULL。
+    /// </summary>
+    private static AdoS0LineSkillDetail MapSkill(AdoS0LineSkillMaster master, AdoS0LineSkillDetailUpsertDto s,
+        long tenantId, IReadOnlyDictionary<string, long> skillMap)
     {
+        var code = (s.PersonSkillCode ?? string.Empty).Trim();
         return new AdoS0LineSkillDetail
         {
             TenantId = tenantId,
-            LineSkillMasterId = lineSkillMasterId,
-            PersonSkillId = s.PersonSkillId,
+            LineSkillMasterId = master.Id,
+            PersonSkillId = skillMap[code],
+            Domain = master.Domain,
+            Site = master.Site,
+            ProdLine = master.ProdLine,
+            JOBNo = master.JOBNo,
+            SkillNo = code,
             RequiredLevel = s.RequiredLevel,
             Remark = s.Remark,
             EffectiveDate = s.EffectiveDate,

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Manufacturing/AdoS0ManufacturingDtos.cs

@@ -862,8 +862,14 @@ public class AdoS0LineSkillMasterQueryDto
 
 public class AdoS0LineSkillDetailUpsertDto
 {
-    [Range(1, long.MaxValue, ErrorMessage = "人员技能不能为空")]
-    public long PersonSkillId { get; set; }
+    /// <summary>
+    /// 人员技能【业务编码】(PersonSkill.Code),如 "102"。
+    /// 客户端只提交业务编码;服务端按当前登录租户解析出 PersonSkill.Id 再落库,
+    /// **不信任客户端提交的内部主键**(避免越权引用他租户主数据)。
+    /// </summary>
+    [Required(ErrorMessage = "人员技能不能为空")]
+    [MaxLength(100)]
+    public string PersonSkillCode { get; set; } = string.Empty;
 
     public string? RequiredLevel { get; set; }
     public string? Remark { get; set; }

+ 38 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Manufacturing/AdoS0LineSkillDetail.cs

@@ -15,6 +15,30 @@ public class AdoS0LineSkillDetail : ITenantIdFilter
     [SugarColumn(ColumnName = "PersonSkillId", ColumnDescription = "人员技能主数据ID", ColumnDataType = "bigint")]
     public long PersonSkillId { get; set; }
 
+    // ---- 旧系统遗留业务键列 ----
+    // 这些列与 IX_LineSkillDetail 唯一索引同源,历史数据(含 1.0.412 种子)全部有值。
+    // 此前实体未映射它们,导致 Update 的「删除+重插」把整行的 Domain/ProdLine/JOBNo/SkillNo
+    // 写成 NULL —— 明细因此丢失业务键。这里补映射,由服务端从主表与已解析的技能主数据回填。
+    //
+    // ⚠️ SkillNo 与 PersonSkillId 语义不同,不得互相顶替:
+    //    SkillNo       = 技能【业务编码】快照(字符串,来自 PersonSkill.Code)
+    //    PersonSkillId = 指向 PersonSkill 主数据的【内部主键】(bigint FK)
+    //    当前数据里两者恰好都出现 "102",纯属编码巧合,不是同一个字段。
+    [SugarColumn(ColumnName = "Domain", ColumnDescription = "工厂域(旧系统遗留)", Length = 50, IsNullable = true)]
+    public string? Domain { get; set; }
+
+    [SugarColumn(ColumnName = "Site", ColumnDescription = "站点/工作组(旧系统遗留,当前全库为 NULL)", Length = 12, IsNullable = true)]
+    public string? Site { get; set; }
+
+    [SugarColumn(ColumnName = "ProdLine", ColumnDescription = "生产线编码(旧系统遗留)", Length = 100, IsNullable = true)]
+    public string? ProdLine { get; set; }
+
+    [SugarColumn(ColumnName = "JOBNo", ColumnDescription = "岗位编码(旧系统遗留)", Length = 100, IsNullable = true)]
+    public string? JOBNo { get; set; }
+
+    [SugarColumn(ColumnName = "SkillNo", ColumnDescription = "技能业务编码快照(旧系统遗留,= PersonSkill.Code)", Length = 100, IsNullable = true)]
+    public string? SkillNo { get; set; }
+
     [SugarColumn(ColumnName = "required_level", ColumnDescription = "要求等级", Length = 50, IsNullable = true)]
     public string? RequiredLevel { get; set; }
 
@@ -30,6 +54,20 @@ public class AdoS0LineSkillDetail : ITenantIdFilter
     [SugarColumn(ColumnName = "CreateTime", ColumnDescription = "创建时间")]
     public DateTime CreateTime { get; set; } = DateTime.Now;
 
+    /// <summary>
+    /// 人员技能【业务编码】(PersonSkill.Code),不落库、仅用于展示与提交。
+    /// PersonSkillId 是内部自增主键,绝不作为前端 select 的业务值;
+    /// UI 一律用 Code,保存时由服务端 tenant-safe 解析成 Id。
+    /// 注意:与本表的 legacy 列 SkillNo 语义不同 —— SkillNo 是旧系统自带的技能编码快照,
+    /// PersonSkillCode 是当前 PersonSkill 主数据的编码,二者可能不同源,不得互相顶替。
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string? PersonSkillCode { get; set; }
+
+    /// <summary>人员技能名称(PersonSkill.Name),不落库、仅展示。</summary>
+    [SugarColumn(IsIgnore = true)]
+    public string? PersonSkillName { get; set; }
+
     [SugarColumn(ColumnName = "tenant_id", IsNullable = true)]
     public long? TenantId { get; set; }
 }