浏览代码

fix(s0): preserve unchanged legacy references on update for routing / production line

已有历史引用值在 no-op 保存时被严格 reference checker 重新校验而 400,
导致带历史值的行即使什么都不改也无法保存。

【新增独立兼容合同 · LEGACY REFERENCE PRESERVATION】
UPDATE 时,若引用值与库中原值完全未变化,且影响该引用合法性的 scope 字段
也完全未变化,则允许原值 preserve,跳过这一项的重新校验。
引用被修改、scope 被修改、以及 CREATE,一律走当前严格校验。
本合同与既有 D-03 并列,**不改动 D-03 的语义与条件**。

作用域依据(按 AdoS0ReferenceChecker 实现推导,非假设):
  Routing.MaterialCode    MaterialExistsInScopeAsync(tenantId, itemNum, CompanyRefId, FactoryRefId)
                          -> scope 字段 = CompanyRefId + FactoryRefId
  Routing.WorkCenterCode  WorkCenterExistsAsync(tenantId, code)      -> 仅 tenantId
  ProductionLine.Location LocationExistsAsync(tenantId, location)    -> 仅 tenantId,6 个库位字段逐个独立判定

实现落在 Update 编排层,三个底层 checker 一行未改,仍是严格语义
(没有「查不到就放行」)。比较用与落库一致的归一化(Trim + 空串视为 null),
避免仅因空白差异被误判为「已修改」。preserve 命中写 [LegacyRefPreserve] 审计日志。

实测(真实页面 + 同源受控请求):
  no-op 保存 200:普通行 94590 / legacy material 106400(91HC0497,scope 1000/1000)
                  / legacy WC 106429(ZJ01) / T01 库位「脱包车间周转仓」
  改成不存在值仍 400:material / workCenter / location 各一次
  CREATE 仍严格 400:不存在的 material / workCenter / location
  CREATE 用历史值当新值同样 400:ZJ01、脱包车间周转仓
  保存前后全列 diff:4 行均无业务列变化,含实体未映射的
                    ProcessOutDay / ProcessOutSupp / line_describe 亦未被清空
  数据不变量:routing 155 / production-line 20 / StdOp='0010' 36 / ZJ0x 3 全部未变,
             未产生任何测试残留行

未新增 migration,DB migration max 仍为 1.0.412。

chore: bump version server 1.0.415
YY968XX 20 小时之前
父节点
当前提交
961a98babb

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.414</AssemblyVersion>
-    <FileVersion>1.0.414</FileVersion>
-    <Version>1.0.414</Version>
+    <AssemblyVersion>1.0.415</AssemblyVersion>
+    <FileVersion>1.0.415</FileVersion>
+    <Version>1.0.415</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 31 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgProductionLinesController.cs

@@ -25,20 +25,38 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     /// <summary>
     /// <summary>
     /// B1-9:LineMaster 6 个 Location 变体的统一 existence check。
     /// B1-9:LineMaster 6 个 Location 变体的统一 existence check。
     /// 全部复用 AdoS0ReferenceChecker.LocationExistsAsync,空值放行。
     /// 全部复用 AdoS0ReferenceChecker.LocationExistsAsync,空值放行。
+    ///
+    /// LEGACY REFERENCE PRESERVATION(与 D-03 并列的独立兼容合同,不改 D-03 语义):
+    ///   UPDATE 时,若某个库位字段的值与库中原值**完全未变化**,且该引用的校验作用域
+    ///   (LocationExistsAsync 只取 tenantId,UPDATE 中租户不可变)也未变化,
+    ///   则允许原值 preserve,跳过这一个字段的存在性校验。
+    ///   任何被**修改过**的值仍走严格校验;CREATE 传 existing = null,永远严格。
+    ///
+    ///   动机:T01 的主库位是历史描述文本「脱包车间周转仓」,不在 LocationMaster 中。
+    ///   在此之前,用户即使什么都不改也无法保存该行。
+    ///   本豁免只允许「旧值 → 同一个旧值」,绝不放行任何新的非法值。
     /// </summary>
     /// </summary>
-    private async Task<IActionResult?> ValidateLocationReferencesAsync(AdoS0LineMasterUpsertDto dto, long tenantId)
+    private async Task<IActionResult?> ValidateLocationReferencesAsync(
+        AdoS0LineMasterUpsertDto dto, long tenantId, AdoS0LineMaster? existing = null)
     {
     {
-        var checks = new (string? Value, string Label)[]
+        var checks = new (string? Value, string? Original, string Label)[]
         {
         {
-            (dto.Location, "主库位"),
-            (dto.VLocation, "虚拟库位"),
-            (dto.Location2, "辅助库位2"),
-            (dto.Location3, "辅助库位3"),
-            (dto.PickingLocation, "拣料库位"),
-            (dto.MidLocation, "中间库位"),
+            (dto.Location, existing?.Location, "主库位"),
+            (dto.VLocation, existing?.VLocation, "虚拟库位"),
+            (dto.Location2, existing?.Location2, "辅助库位2"),
+            (dto.Location3, existing?.Location3, "辅助库位3"),
+            (dto.PickingLocation, existing?.PickingLocation, "拣料库位"),
+            (dto.MidLocation, existing?.MidLocation, "中间库位"),
         };
         };
-        foreach (var (value, label) in checks)
+        // 与落库归一化保持一致(Trim + 空串视为 null),避免仅因空白差异被判为「已修改」
+        static string? Normalize(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
+
+        foreach (var (value, original, label) in checks)
         {
         {
+            // 值未变化(UPDATE 且与原值逐字相同)→ preserve,不重新校验
+            if (existing != null && string.Equals(Normalize(value), Normalize(original), StringComparison.Ordinal))
+                continue;
+
             if (!await _refChecker.LocationExistsAsync(tenantId, value))
             if (!await _refChecker.LocationExistsAsync(tenantId, value))
                 return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                     $"{label}编码 '{value}' 不存在于库位主数据");
                     $"{label}编码 '{value}' 不存在于库位主数据");
@@ -148,12 +166,13 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LineMasterUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LineMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        var locErr = await ValidateLocationReferencesAsync(dto, tenantId);
-        if (locErr != null) return locErr;
-
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
+        // 传入库中原记录 → 未变化的历史库位值可 preserve;被修改的值仍严格校验
+        var locErr = await ValidateLocationReferencesAsync(dto, tenantId, entity);
+        if (locErr != null) return locErr;
+
         entity.CompanyRefId = dto.CompanyRefId;
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.Domain = dto.Domain.Trim();
         entity.Domain = dto.Domain.Trim();

+ 47 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -100,8 +100,41 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
+        // ── LEGACY REFERENCE PRESERVATION(独立兼容合同,不改动下方 D-03 语义)─────────
+        // UPDATE 时,若引用值与库中原值完全未变化,且影响该引用合法性的 scope 字段
+        // 也完全未变化,则允许原值 preserve,跳过这一项的重新校验。
+        // 任何被修改过的引用、以及 CREATE,一律走下方严格校验;
+        // 底层 checker 语义保持不变(不在 checker 里「查不到就放行」)。
+        //
+        // 各引用的 scope 依据(按 AdoS0ReferenceChecker 实现推导,非猜测):
+        //   MaterialCode   -> MaterialExistsInScopeAsync(tenantId, itemNum, CompanyRefId, FactoryRefId)
+        //                     故 scope 字段 = CompanyRefId + FactoryRefId
+        //   WorkCenterCode -> WorkCenterExistsAsync(tenantId, workCenterCode)
+        //                     只取 tenantId,UPDATE 中租户不可变 → 无额外 scope 字段
+        //
+        // 动机:91HC0497 那批历史行 scope 为 1000/1000,其物料主数据 scope 为 1000/8010,
+        // 判定 ScopeMiss 而又不满足 D-03 的「原 scope 为 0/0」,导致即使什么都不改也无法保存;
+        // ZJ01/ZJ02/ZJ03 同理不在 WorkCtrMaster 中。本豁免只允许「旧值 → 同一个旧值」。
+        static string? Norm(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
+
+        var scopeUnchanged = entity.CompanyRefId == dto.CompanyRefId && entity.FactoryRefId == dto.FactoryRefId;
+        var materialPreserved = scopeUnchanged
+            && string.Equals(Norm(entity.MaterialCode), Norm(dto.MaterialCode), StringComparison.Ordinal);
+        var workCenterPreserved =
+            string.Equals(Norm(entity.WorkCenterCode), Norm(dto.WorkCenterCode), StringComparison.Ordinal);
+
+        if (materialPreserved)
+        {
+            _logger.LogInformation(
+                "[LegacyRefPreserve] Table=RoutingOpDetail PrimaryKey={PrimaryKey} ReferenceField=MaterialCode " +
+                "Value={Value} CompanyRefId={CompanyRefId} FactoryRefId={FactoryRefId} Reason=UnchangedValueAndScope",
+                entity.Id, entity.MaterialCode, entity.CompanyRefId, entity.FactoryRefId);
+        }
+
         // B1 + B2 + 历史兼容降级(D-03 严格条件)
         // B1 + B2 + 历史兼容降级(D-03 严格条件)
-        var matResult = await _refChecker.MaterialExistsInScopeAsync(tenantId, dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
+        var matResult = materialPreserved
+            ? MaterialScopeCheck.Match
+            : await _refChecker.MaterialExistsInScopeAsync(tenantId, dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
         if (matResult == MaterialScopeCheck.NotFound)
         if (matResult == MaterialScopeCheck.NotFound)
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
                 $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
@@ -135,9 +168,21 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
             }
             }
         }
         }
 
 
-        if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
+        // 工作中心:值未变化即 preserve(scope 仅 tenantId,UPDATE 中不可变);
+        // 改成任何其它值仍必须通过 WorkCenterExistsAsync。
+        if (workCenterPreserved)
+        {
+            if (!string.IsNullOrWhiteSpace(entity.WorkCenterCode))
+                _logger.LogInformation(
+                    "[LegacyRefPreserve] Table=RoutingOpDetail PrimaryKey={PrimaryKey} ReferenceField=WorkCenterCode " +
+                    "Value={Value} Reason=UnchangedValue",
+                    entity.Id, entity.WorkCenterCode);
+        }
+        else if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
+        {
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
                 $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
+        }
 
 
         ApplyUpsert(entity, dto);
         ApplyUpsert(entity, dto);
         entity.UpdatedAt = DateTime.Now;
         entity.UpdatedAt = DateTime.Now;