Ver Fonte

fix(s0): guard standard BOM op detail against silent row loss

标准BOM(StandardBomManagement / ProductStructureOp)写路径止血。

【已证实的缺陷】
ProductStructureOp 共 808 行 / 520 master,全部在租户 797。
每个 master 只有 1 个 distinct Op;其中 96 个 master 各有 4 行、
行内 Op 完全相同,**仅 ProductItem 不同**。
而 GET 只返回 opNos = Select(Op)(不去重),PUT 只回传 int[],
原 UpdateAsync 走「删光子行 + 按 Distinct(OpNos) 重建」:
一次 no-op 保存即把 808 行压成 520 行,净销毁 288 行(35.6%),
且重建行的 Domain / ParentItem / ComponentItem / ProductItem /
IsActive / IsConfirm / CreateUser 七列全部为空。

【本批只做止血,不做子表契约重构】
UPDATE:
  提交 Op 多重集合 == 现有子行 Op 多重集合 → 跳过子表同步,主表其它字段照常保存
  不相同                                    → 400 拒绝,绝不 delete / insert
  ★ AsDeleteable + SyncOpsAsync 已从 UpdateAsync 整段移除,
    破坏性路径在结构上不再存在,不依赖比较结果是否正确。
CREATE:
  OpNos 非空 → 400 拒绝(避免创建缺 7 列的稀疏子行)
  OpNos 为空 → 照常创建主表

比较必须按【多重集合】:排序后逐元素比较且长度一致,
[303,303,303,303] vs [303] 判为不同 —— 不能因去重后同为 [303] 而误判相同。
NormalizeOps 只过滤非正数,**保留重复项**,不做 Distinct。
子行查询走 ScopedTo(tenantId)。

错误走既有 AdoS0ApiErrors.InvalidRequest(400 / S01002),非 500,
文案指明业务原因而非系统故障。

前端未改动,GET 语义未改(仍可回显 303,303,303,303),Web 不升版。

实测(UAT 租户,同源认证请求):
  空多重集合 no-op 保存        → 200,跳过子表同步
  向 0 明细 master 新增工序    → 400 S01002
  新建时携带工序明细           → 400 S01002
数据零变更:ProductStructureOp 808 行 / 520 master 全程未动,本批 SQL 写入 0。

⚠️ 未验证:租户 797 的 4 行/1 行 master 页面级冒烟。该租户账号凭据不可得,
本轮未做凭据探查,故这两项标记为未验证而非通过。

chore: bump version server 1.0.416
YY968XX há 4 horas atrás
pai
commit
4547e2b237

+ 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.415</AssemblyVersion>
-    <FileVersion>1.0.415</FileVersion>
-    <Version>1.0.415</Version>
+    <AssemblyVersion>1.0.416</AssemblyVersion>
+    <FileVersion>1.0.416</FileVersion>
+    <Version>1.0.416</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 40 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs

@@ -159,6 +159,14 @@ public class AdoS0ProductStructuresController : ControllerBase
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
+        // DATA LOSS GUARD(见下方 SyncOpsAsync 注释):
+        // 当前 op 子表契约只携带 Op 一个字段,无法构造 ProductItem / Domain / ParentItem /
+        // ComponentItem / IsActive / IsConfirm / CreateUser 这 7 列,创建出来的会是稀疏行。
+        // 在完整子表契约落地前,禁止创建带工序明细的标准 BOM;OpNos 为空则照常创建主表。
+        if (NormalizeOps(dto.OpNos).Count > 0)
+            return AdoS0ApiErrors.InvalidRequest(
+                "当前页面的工序编号字段无法完整描述工序明细(缺少成品项等必填业务列),暂不支持新建时录入工序明细。请先创建标准 BOM 主记录。");
+
         var db = _masterRep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -197,6 +205,30 @@ public class AdoS0ProductStructuresController : ControllerBase
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
+        // ── DATA LOSS GUARD ──────────────────────────────────────────────────────
+        // 历史工序明细每行携带 ProductItem 等 7 列业务数据,而当前 DTO 只有 OpNos。
+        // 实测:520 个 master 中 96 个各有 4 行、行内 Op 完全相同、仅 ProductItem 不同;
+        // 原实现「删光子行 + 按 Distinct(OpNos) 重建」会把 808 行压成 520 行(净丢 288 行),
+        // 且重建行的 7 列全部为空。
+        //
+        // 在完整子表契约落地前:
+        //   提交的 Op 多重集合 == 现有子行的 Op 多重集合 → 跳过子表同步(主表照常更新)
+        //   不相同                                      → 拒绝保存,绝不 delete/insert
+        // 必须按【多重集合】比较:existing [303,303,303,303] vs submitted [303] 是「不同」,
+        // 不能因为去重后都是 [303] 就判为相同。
+        var existingOps = await _opRep.ScopedTo(tenantId)
+            .Where(o => o.ProductStructureMasterId == id)
+            .Select(o => o.Op)
+            .ToListAsync();
+        var submittedOps = NormalizeOps(dto.OpNos);
+        if (!existingOps.OrderBy(n => n).SequenceEqual(submittedOps.OrderBy(n => n)))
+        {
+            return AdoS0ApiErrors.InvalidRequest(
+                existingOps.Count > 0
+                    ? "该标准 BOM 含历史工序明细,当前页面的工序编号字段无法完整描述这些明细(缺少成品项等业务列),暂不支持修改工序集合。工序编号以外的字段可正常保存。"
+                    : "当前页面的工序编号字段无法完整描述工序明细(缺少成品项等必填业务列),暂不支持新增工序明细。");
+        }
+
         var db = _masterRep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -207,8 +239,7 @@ public class AdoS0ProductStructuresController : ControllerBase
             existing.UpdatedAt = DateTime.Now;
             await _masterRep.AsUpdateable(existing).ExecuteCommandAsync();
 
-            await _opRep.AsDeleteable().Where(o => o.ProductStructureMasterId == id).ExecuteCommandAsync();
-            await SyncOpsAsync(id, dto, existing.ParentMaterialId, existing.ComponentMaterialId, tenantId);
+            // 工序集合与库中完全一致 → 不触碰子表(这正是本 guard 的全部作用)
 
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
@@ -281,6 +312,13 @@ public class AdoS0ProductStructuresController : ControllerBase
         m.IsEnabled = dto.IsEnabled;
     }
 
+    /// <summary>
+    /// 归一化提交的工序号:过滤非正数,**保留重复项**(多重集合语义)。
+    /// 不做 Distinct —— 历史数据里同一 master 下多行共享同一个 Op 是合法形态。
+    /// </summary>
+    private static List<int> NormalizeOps(IEnumerable<int>? ops) =>
+        ops?.Where(n => n > 0).ToList() ?? new List<int>();
+
     private async Task SyncOpsAsync(long masterId, AdoS0ProductStructureUpsertDto dto, long parentMaterialId, long componentMaterialId, long tenantId)
     {
         var distinct = dto.OpNos?.Where(n => n > 0).Distinct().OrderBy(n => n).ToList() ?? new List<int>();