Ver código fonte

fix(s0): preserve persisted empty spec details on update | Web 2.4.354 / server 1.0.452

阶段 1.1 收口。前一提交(84f23e661)把「全空明细不落库」实现成
`incoming.Where(Meaningful)` 后全量重建,而检规明细的 Update 是 delete-all + 重建语义,
导致用户只要编辑一次头字段,库里**历史已存在的空明细就会被静默删掉**
——实测 aidopdev 有 10 条这样的行,分布在 9 张真实业务主单上(如 W00-1-80 的 69 条明细含 2 条)。
本轮不是历史数据清洗批次,故按 detail identity 区分。

- 新增 ForUpdateWrite(...):Update 的写入集 = Meaningful ∪ 已持久化行(带 Id)。
  带 Id = 库里已有的行,即使为空也照原样写回;无 Id = 本次 UI/Excel 新补的空占位,丢弃。
  Create / Import 仍只写 Meaningful,任何空明细都不落库。
- 新增实体侧 CountMeaningful(...):「原有 ≥1 明细不许删空」改按 meaningful 计。
  原按 raw count 会让一张明细全为空的历史主单被自己的空占位永久锁死、无法保存。
- 前端 hasItemContent 只看业务列:编辑态加载回来的明细带 id/tenantId/masterId,
  原用 Object.values 判断会恒为真、判据形同虚设;originalItemCount 同步改按 meaningful 计。

运行态验证(租户 838,API + DB 双证):Create 0/全空明细均 400;Import 坏档与
「合法A+非法B」混合档均 accepted=false 且 A 亦未落库(全或无);合法档 3 行 → 1 主单 2 明细,
空占位未写库;历史缺字段与历史 0 明细记录均可编辑保存且不新增空明细;
清空已有必填字段、明细删到 0 均被拒;过程检规上构造的真空明细在只改头字段后仍保留。
两表历史空明细 9 + 1 全程未减少。
YY968XX 6 dias atrás
pai
commit
2fdc95ad37

+ 1 - 1
Web/package.json

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

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

@@ -161,9 +161,13 @@ function isBlank(value: unknown): boolean {
 	return value === null || value === undefined || String(value).trim() === '';
 }
 
-/** 与后端 AdoS0QmsSpecAggregateGuard.HasValue 同口径:整行全空的明细不计数。 */
+/**
+ * 与后端 AdoS0QmsSpecAggregateGuard.HasValue 同口径:整行全空的明细不计数。
+ * 只看业务列——编辑态加载回来的明细带 id / tenantId / masterId,
+ * 若用 Object.values 判断会恒为真,判据形同虚设。
+ */
 function hasItemContent(item: Record<string, any>): boolean {
-	return Object.values(item).some((v) => !isBlank(v));
+	return props.config.detailColumns.some((c) => !isBlank(item[c.prop]));
 }
 
 /** 本页用到的全部 lookup(列表列 + 头表字段 + 明细列去重)——每个 key 只取一次,无 per-row 请求 */
@@ -266,7 +270,8 @@ async function openEdit(row: Record<string, any>) {
 	Object.assign(master, structuredClone(detail.master ?? {}));
 	items.value = structuredClone(detail.items ?? []);
 	originalMaster.value = structuredClone(detail.master ?? {});
-	originalItemCount.value = items.value.length;
+	// 按 meaningful 计,与后端 CountMeaningful 一致:历史若本就 0 条有内容的明细,允许继续保持 0
+	originalItemCount.value = items.value.filter(hasItemContent).length;
 	normalizeLookupValues(master, props.config.headFields);
 	items.value.forEach((it) => normalizeLookupValues(it, props.config.detailColumns));
 	dialogVisible.value = true;

+ 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.451</AssemblyVersion>
-    <FileVersion>1.0.451</FileVersion>
-    <Version>1.0.451</Version>
+    <AssemblyVersion>1.0.452</AssemblyVersion>
+    <FileVersion>1.0.452</FileVersion>
+    <Version>1.0.452</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 74 - 11
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Quality/AdoS0QmsSpecAggregateGuardTests.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
 using Xunit;
 
 namespace Admin.NET.Plugin.AiDOP.Tests.S0.Quality;
@@ -55,7 +56,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     {
         // 实测 aidopdev 有 72 条历史记录缺必填字段;它们必须仍可编辑其他字段。
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", null, null), ("版本", "V1", "V2")], existingItemCount: 3, incomingMeaningfulItemCount: 3);
+            [("物料编码", null, null), ("版本", "V1", "V2")], existingMeaningfulItemCount: 3, incomingMeaningfulItemCount: 3);
         Assert.Null(error);
     }
 
@@ -64,7 +65,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     {
         // 实测 22 条历史记录 0 明细。
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: 0);
+            [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 0, incomingMeaningfulItemCount: 0);
         Assert.Null(error);
     }
 
@@ -72,7 +73,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     public void Update_WhenHistoricalBlankFieldGetsFilled_Passes()
     {
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", "", "M-001")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+            [("物料编码", "", "M-001")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
         Assert.Null(error);
     }
 
@@ -82,7 +83,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     public void Update_WhenClearingAPopulatedRequiredField_IsRejected()
     {
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", "M-001", "  ")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+            [("物料编码", "M-001", "  ")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
         Assert.Equal("物料编码原本已有值,不允许清空", error);
     }
 
@@ -90,7 +91,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     public void Update_WhenDeletingAllExistingDetails_IsRejected()
     {
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", "M-001", "M-001")], existingItemCount: 5, incomingMeaningfulItemCount: 0);
+            [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 5, incomingMeaningfulItemCount: 0);
         Assert.Equal("该检规原有 5 条检验明细,不允许全部删除", error);
     }
 
@@ -107,7 +108,7 @@ public class AdoS0QmsSpecAggregateGuardTests
                 ("物料编码", "M-001", "M-002"),
                 ("版本", "V1", "V1")
             ],
-            existingItemCount: 2, incomingMeaningfulItemCount: 2);
+            existingMeaningfulItemCount: 2, incomingMeaningfulItemCount: 2);
         Assert.Null(error);
     }
 
@@ -116,7 +117,7 @@ public class AdoS0QmsSpecAggregateGuardTests
     public void Update_WhenClearingAnExistingFileNumber_IsRejected()
     {
         var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("文件编号", "F-001", "")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+            [("文件编号", "F-001", "")], existingMeaningfulItemCount: 1, incomingMeaningfulItemCount: 1);
         Assert.Equal("文件编号原本已有值,不允许清空", error);
     }
 
@@ -162,11 +163,73 @@ public class AdoS0QmsSpecAggregateGuardTests
     [Fact]
     public void Update_WhenHistoricalHadNoDetailAndUiAddsBlankPlaceholder_WritesNothing()
     {
-        var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsProcessInspectionSpecEntryDto> { new() });
+        // UI 新补的空占位没有 Id → 不进写入集
+        var written = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(new List<AdoS0QmsProcessInspectionSpecEntryDto> { new() });
 
-        Assert.Empty(kept); // 写库用的就是这份 → 不会新增全空明细
+        Assert.Empty(written); // 写库用的就是这份 → 不会新增全空明细
         Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-            [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: kept.Count));
+            [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 0, incomingMeaningfulItemCount: 0));
+    }
+
+    /// <summary>
+    /// 阶段 1.1 · 场景 10:Update 是 delete-all + 全量重建语义,
+    /// 因此写入集必须保留**历史已持久化**的空明细(带 Id),否则用户编辑一次头字段
+    /// 就会静默删掉库里的空行——实测 aidopdev 有 10 条这样的行,分布在 9 张真实主单上。
+    /// </summary>
+    [Fact]
+    public void Update_KeepsPersistedEmptyDetail_ButDropsNewEmptyPlaceholder()
+    {
+        var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto>
+        {
+            new() { Id = 101, InspectionItem = "外观" },   // 历史真实明细
+            new() { Id = 102 },                            // 历史已持久化的空明细 → 必须保留
+            new() { InspectionItem = "  " },               // 本次 UI 新补的空占位 → 必须丢弃
+        };
+
+        var written = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(incoming);
+
+        Assert.Equal(2, written.Count);
+        Assert.Equal(new long?[] { 101, 102 }, written.Select(x => x.Id).ToArray());
+        // 数量校验仍只认 meaningful:这里只有 1 条有内容
+        Assert.Single(AdoS0QmsSpecAggregateGuard.Meaningful(incoming));
+    }
+
+    /// <summary>Create 侧相反:任何空明细都没有 Id,一律不落库。</summary>
+    [Fact]
+    public void Create_NeverPersistsEmptyDetail_EvenIfClientSuppliesAnId()
+    {
+        var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto> { new() { InspectionItem = "外观" }, new() { Id = 999 } };
+
+        // Create 走 Meaningful,不走 ForUpdateWrite → 带 Id 的空行同样不落库
+        Assert.Single(AdoS0QmsSpecAggregateGuard.Meaningful(incoming));
+    }
+
+    /// <summary>
+    /// 「原有 ≥1 明细不许删空」必须按 meaningful 计。
+    /// 若按 raw count,一张明细全为空的历史主单会被自己的空占位永久锁死、无法保存。
+    /// </summary>
+    [Fact]
+    public void Update_WhenAllExistingDetailsAreEmpty_IsNotLockedOut()
+    {
+        var existingRows = new List<AdoS0QmsRawInspectionSpecEntry> { new(), new() }; // 2 条空行
+        var existingMeaningful = AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows);
+
+        Assert.Equal(0, existingMeaningful);
+        Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "M-001", "M-001")], existingMeaningful, incomingMeaningfulItemCount: 0));
+    }
+
+    [Fact]
+    public void CountMeaningful_OnEntities_MatchesDtoSemantics()
+    {
+        Assert.Equal(1, AdoS0QmsSpecAggregateGuard.CountMeaningful(new List<AdoS0QmsRawInspectionSpecEntry>
+        {
+            new() { InspectionItem = "外观" }, new() { InspectionItem = "   " }, new()
+        }));
+        Assert.Equal(2, AdoS0QmsSpecAggregateGuard.CountMeaningful(new List<AdoS0QmsProcessInspectionSpecEntry>
+        {
+            new() { OperationCode = "OP10" }, new() { PeelingForce = 5 }, new()
+        }));
     }
 
     /// <summary>阶段 1.1 · 场景 6:原有真实明细被删到只剩空占位 → 按「删到 0」拒绝。</summary>
@@ -178,7 +241,7 @@ public class AdoS0QmsSpecAggregateGuardTests
         Assert.Empty(kept);
         Assert.Equal("该检规原有 3 条检验明细,不允许全部删除",
             AdoS0QmsSpecAggregateGuard.ValidateUpdate(
-                [("物料编码", "M-001", "M-001")], existingItemCount: 3, incomingMeaningfulItemCount: kept.Count));
+                [("物料编码", "M-001", "M-001")], existingMeaningfulItemCount: 3, incomingMeaningfulItemCount: kept.Count));
     }
 
     /// <summary>阶段 1.1 · 场景 7:导入侧口径与实体侧同源——空明细行不落库,全空 aggregate 拒绝。</summary>

+ 58 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecAggregateGuard.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
@@ -39,7 +40,7 @@ public static class AdoS0QmsSpecAggregateGuard
     /// <summary>编辑校验(历史兼容、禁止劣化)。返回 null 表示通过,否则为面向用户的错误文案。</summary>
     public static string? ValidateUpdate(
         IReadOnlyList<(string Label, string? Existing, string? Incoming)> requiredFields,
-        int existingItemCount,
+        int existingMeaningfulItemCount,
         int incomingMeaningfulItemCount)
     {
         foreach (var (label, existing, incoming) in requiredFields)
@@ -48,14 +49,29 @@ public static class AdoS0QmsSpecAggregateGuard
             if (!string.IsNullOrWhiteSpace(existing) && string.IsNullOrWhiteSpace(incoming))
                 return $"{label}原本已有值,不允许清空";
         }
-        if (existingItemCount > 0 && incomingMeaningfulItemCount == 0)
-            return $"该检规原有 {existingItemCount} 条检验明细,不允许全部删除";
+        // 两侧都按 meaningful 计:历史若本就 0 条有内容的明细,允许继续保持 0。
+        if (existingMeaningfulItemCount > 0 && incomingMeaningfulItemCount == 0)
+            return $"该检规原有 {existingMeaningfulItemCount} 条检验明细,不允许全部删除";
         return null;
     }
 
-    // ==================== 明细归一化:过滤全空占位行 ====================
-    // 唯一入口。控制器一律用 Meaningful(...) 的返回值同时做数量校验与写库,
-    // 不得再各自 Where/Count 一遍,避免「校验一套、写入另一套」。
+    // ==================== 明细归一化 ====================
+    //
+    // 两个不同的集合,不可混用:
+    //
+    // · Meaningful(...)     —— 「有业务内容」的明细。用于 Create 的写入集,以及三条入口的**数量校验**。
+    // · ForUpdateWrite(...) —— Update 的写入集 = Meaningful ∪ 已持久化行(带 Id)。
+    //
+    // 为什么 Update 需要单独一个写入集:Update 的明细是 **delete-all + 全量重建** 语义
+    // (`AsDeleteable().Where(MasterId==id)` 后逐条 Insert,且 SyncXxxEntriesAsync 不回填 item.Id)。
+    // 若直接拿 Meaningful 去重建,用户只是编辑一次头字段,库里**历史已存在的空明细就会被静默删掉**
+    // ——实测 aidopdev 有 10 条这样的空明细,分布在 9 张真实业务主单上(如 W00-1-80 的 69 条明细里有 2 条)。
+    // 本轮不是历史数据清洗批次,因此按 detail identity 区分:
+    //   带 Id  = 库里已有的行 → 即使为空也照原样写回,不主动删除;
+    //   无 Id  = 本次 UI/Excel 新补的空占位 → 丢弃,不新增空记录。
+    //
+    // ⚠️ 已知既有局限(非本轮引入):重建会让所有明细拿到新的雪花 Id,明细身份本就不跨 Update 保持。
+    //    这里只保证**内容**不被顺手清理;若将来要真正保持身份,需另开批次改成按 Id diff 的增量更新。
 
     public static List<AdoS0QmsRawInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
         => items.Where(HasValue).ToList();
@@ -69,6 +85,42 @@ public static class AdoS0QmsSpecAggregateGuard
     public static List<AdoS0QmsOqcInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
         => items.Where(HasValue).ToList();
 
+    public static List<AdoS0QmsRawInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
+        => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
+
+    public static List<AdoS0QmsProcessInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items)
+        => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
+
+    public static List<AdoS0QmsFqcInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items)
+        => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
+
+    public static List<AdoS0QmsOqcInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
+        => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
+
+    // 实体侧判据:统计库里**现存的 meaningful 明细数**。
+    // 「原有 ≥1 明细不许删空」必须按 meaningful 计数,否则一张明细全为空的历史主单
+    // 会因 raw count>0 而永远保存不了(被自己的空占位锁死)。
+
+    public static int CountMeaningful(IEnumerable<AdoS0QmsRawInspectionSpecEntry> rows)
+        => rows.Count(e => e.Seq.HasValue
+            || AnyNotBlank(e.InspectionItem, e.InspectionStandard, e.InspectionMethod, e.ImageCategory,
+                e.SamplingScheme, e.Remark, e.Attachment, e.UpperLimit, e.LowerLimit));
+
+    public static int CountMeaningful(IEnumerable<AdoS0QmsProcessInspectionSpecEntry> rows)
+        => rows.Count(e => e.PeelingForce.HasValue
+            || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+                e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
+
+    public static int CountMeaningful(IEnumerable<AdoS0QmsFqcInspectionSpecEntry> rows)
+        => rows.Count(e => e.PeelingForce.HasValue
+            || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+                e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
+
+    public static int CountMeaningful(IEnumerable<AdoS0QmsOqcInspectionSpecEntry> rows)
+        => rows.Count(e => e.PeelingForce.HasValue
+            || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+                e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit));
+
     // ==================== 明细「有内容」判据 ====================
     // 整行全空的明细不计数、不落库,否则前端 openCreate 自动补的那一行空明细
     // 会让「≥1 条」形同虚设,并持续制造新的空明细脏数据。

+ 12 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityAggregateControllers.cs

@@ -544,8 +544,10 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
-        var existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
-        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位;
+        // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。
+        var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync();
+        var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items);
         var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
             [
                 ("文件编号", master.FileNumber, dto.FileNumber),
@@ -553,8 +555,8 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
                 ("原材料名称", master.RawMaterialName, dto.RawMaterialName),
                 ("版本", master.VersionNo, dto.VersionNo)
             ],
-            existingItemCount,
-            items.Count);
+            AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows),
+            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -720,16 +722,18 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
-        var existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
-        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位;
+        // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。
+        var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync();
+        var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items);
         var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
             [
                 ("文件编号", master.FileNumber, dto.FileNumber),
                 ("物料编码", master.MaterialCode, dto.MaterialCode),
                 ("版本", master.VersionNo, dto.VersionNo)
             ],
-            existingItemCount,
-            items.Count);
+            AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows),
+            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();

+ 12 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityFqcOqcControllers.cs

@@ -78,16 +78,18 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
-        var existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
-        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位;
+        // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。
+        var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync();
+        var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items);
         var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
             [
                 ("文件编号", master.FileNumber, dto.FileNumber),
                 ("物料编码", master.MaterialCode, dto.MaterialCode),
                 ("版本", master.VersionNo, dto.VersionNo)
             ],
-            existingItemCount,
-            items.Count);
+            AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows),
+            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -248,16 +250,18 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
-        var existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
-        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位;
+        // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。
+        var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync();
+        var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items);
         var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
             [
                 ("文件编号", master.FileNumber, dto.FileNumber),
                 ("物料编码", master.MaterialCode, dto.MaterialCode),
                 ("版本", master.VersionNo, dto.VersionNo)
             ],
-            existingItemCount,
-            items.Count);
+            AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows),
+            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();