Переглянути джерело

feat(s0): enforce routing material scope validation

YY968XX 1 місяць тому
батько
коміт
9cb551ca06

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Const/AdoS0ErrorCodes.cs

@@ -25,6 +25,12 @@ public static class AdoS0ErrorCodes
     /// </summary>
     public const string ReferenceNotFound = "S01011";
 
+    /// <summary>
+    /// B2 作用域校验失败:引用值在目标主表存在,但 Company/Factory/Domain 与当前记录不匹配。
+    /// 返回 400 BadRequest。严格区别于 <see cref="ReferenceNotFound"/>(不存在 S01011)。
+    /// </summary>
+    public const string InvalidReferenceScope = "S01012";
+
     public const string InternalServerError = "S01999";
 
     public const string CustomerCodeExists = "S02001";

+ 53 - 7
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -2,6 +2,7 @@ using Admin.NET.Plugin.AiDOP.Dto.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Dto.S0.Sales;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Microsoft.Extensions.Logging;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
@@ -13,11 +14,16 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0MfgRoutingOpDetail> _rep;
     private readonly AdoS0ReferenceChecker _refChecker;
+    private readonly ILogger<AdoS0MfgRoutingOpDetailsController> _logger;
 
-    public AdoS0MfgRoutingOpDetailsController(SqlSugarRepository<AdoS0MfgRoutingOpDetail> rep, AdoS0ReferenceChecker refChecker)
+    public AdoS0MfgRoutingOpDetailsController(
+        SqlSugarRepository<AdoS0MfgRoutingOpDetail> rep,
+        AdoS0ReferenceChecker refChecker,
+        ILogger<AdoS0MfgRoutingOpDetailsController> logger)
     {
         _rep = rep;
         _refChecker = refChecker;
+        _logger = logger;
     }
 
     [HttpGet]
@@ -58,9 +64,17 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
-        if (!await _refChecker.MaterialExistsAsync(dto.MaterialCode))
-            return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
-                $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
+        // B1 + B2:Create 一律严格校验,禁止降级
+        var matResult = await _refChecker.MaterialExistsInScopeAsync(dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
+        switch (matResult)
+        {
+            case MaterialScopeCheck.NotFound:
+                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
+                    $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
+            case MaterialScopeCheck.ScopeMiss:
+                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReferenceScope,
+                    $"物料编码 '{dto.MaterialCode}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
+        }
 
         if (!await _refChecker.WorkCenterExistsAsync(dto.WorkCenterCode))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
@@ -78,16 +92,48 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
 
-        if (!await _refChecker.MaterialExistsAsync(dto.MaterialCode))
+        var entity = await _rep.GetByIdAsync(id);
+        if (entity == null) return NotFound();
+
+        // B1 + B2 + 历史兼容降级(D-03 严格条件)
+        var matResult = await _refChecker.MaterialExistsInScopeAsync(dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
+        if (matResult == MaterialScopeCheck.NotFound)
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
 
+        if (matResult == MaterialScopeCheck.ScopeMiss)
+        {
+            // 降级条件(全部满足才允许,对应 D-03 严格条件):
+            //   1. 是 Update(已满足)
+            //   2. DB 原记录 scope 为 0/null/0
+            //   3. 本次未修改 MaterialCode(dto 与 entity 相同)
+            //   4. B1 存在性通过(matResult != NotFound,已满足)
+            var originScopeEmpty = entity.CompanyRefId == 0 && entity.FactoryRefId == 0;
+            var materialUnchanged = string.Equals(entity.MaterialCode, dto.MaterialCode?.Trim(), StringComparison.Ordinal);
+
+            if (originScopeEmpty && materialUnchanged)
+            {
+                _logger.LogWarning(
+                    "[B2 Downgrade] Table={TableName} PrimaryKey={PrimaryKey} ReferenceField={ReferenceField} " +
+                    "OldValue={OldValue} NewValue={NewValue} CompanyRefId={CompanyRefId} FactoryRefId={FactoryRefId} " +
+                    "DowngradeReason={DowngradeReason}",
+                    "RoutingOpDetail", entity.Id, "MaterialCode",
+                    entity.MaterialCode, dto.MaterialCode,
+                    dto.CompanyRefId, dto.FactoryRefId,
+                    "LegacyRecord_OriginScopeZero_MaterialUnchanged");
+                // 放行,继续执行写入
+            }
+            else
+            {
+                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReferenceScope,
+                    $"物料编码 '{dto.MaterialCode}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
+            }
+        }
+
         if (!await _refChecker.WorkCenterExistsAsync(dto.WorkCenterCode))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
                 $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
 
-        var entity = await _rep.GetByIdAsync(id);
-        if (entity == null) return NotFound();
         ApplyUpsert(entity, dto);
         entity.UpdatedAt = DateTime.Now;
         await _rep.AsUpdateable(entity).ExecuteCommandAsync();

+ 55 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -7,6 +7,19 @@ using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Infrastructure;
 
+/// <summary>
+/// B2 物料作用域校验结果。
+/// </summary>
+public enum MaterialScopeCheck
+{
+    /// <summary>引用值存在且作用域一致。</summary>
+    Match,
+    /// <summary>引用值不存在于 ItemMaster(走 S01011)。</summary>
+    NotFound,
+    /// <summary>引用值存在但 Company/Factory 不匹配(走 S01012)。</summary>
+    ScopeMiss
+}
+
 /// <summary>
 /// S0 删除前下游引用检查。
 /// 每个主数据主表在 DELETE 前调用对应方法,若存在下游引用则拒绝删除。
@@ -53,6 +66,34 @@ public sealed class AdoS0ReferenceChecker
             .AnyAsync();
     }
 
+    /// <summary>
+    /// B2 保存端:物料(ItemMaster)存在 + 作用域一致性检查。
+    /// 返回:
+    ///   null       — 引用值空,放行(交给必填校验)
+    ///   NotFound   — 引用值不存在(应返 S01011)
+    ///   ScopeMiss  — 引用值存在但 Company/Factory 不匹配(应返 S01012)
+    ///   Match      — 存在且作用域一致,放行
+    /// 0/0 边界:目标 ItemMaster 的 CompanyRefId=0 且 FactoryRefId=0 被视为"未 scope 化的历史数据",
+    /// 仅当来源 Routing 同为 0/0 时才允许匹配;否则判 ScopeMiss(不做全局物料白名单)。
+    /// </summary>
+    public async Task<MaterialScopeCheck> MaterialExistsInScopeAsync(string? itemNum, long companyRefId, long factoryRefId)
+    {
+        if (string.IsNullOrWhiteSpace(itemNum)) return MaterialScopeCheck.Match;
+
+        var match = await _db.Queryable<AdoS0ItemMaster>()
+            .Where(x => x.ItemNum == itemNum)
+            .Select(x => new { x.CompanyRefId, x.FactoryRefId })
+            .ToListAsync();
+
+        if (match.Count == 0) return MaterialScopeCheck.NotFound;
+
+        // 存在 scope 完全一致的物料(含双方同为 0/0 的历史记录)
+        if (match.Any(m => m.CompanyRefId == companyRefId && m.FactoryRefId == factoryRefId))
+            return MaterialScopeCheck.Match;
+
+        return MaterialScopeCheck.ScopeMiss;
+    }
+
     /// <summary>
     /// 保存端:库位(LocationMaster)存在性检查。
     /// 用于货架等"从属于 LocationMaster 聚合"的子实体写入保护。
@@ -140,7 +181,7 @@ public sealed class AdoS0ReferenceChecker
 
     /// <summary>
     /// 库位(LocationMaster)删除前引用检查。
-    /// 覆盖下游:货架 / 物料默认库位。
+    /// 覆盖下游:货架 / 物料默认库位 / 产线 6 个 Location 变体
     /// </summary>
     public async Task<(int Count, string Table)?> LocationReferencesAsync(string? location)
     {
@@ -156,6 +197,19 @@ public sealed class AdoS0ReferenceChecker
             .CountAsync();
         if (item > 0) return (item, "物料默认库位 (ItemMaster.Location)");
 
+        // 补 A2 缺口:LineMaster 的 6 个 Location 变体均为 LocationMaster 的强软引用。
+        // B1-9 已在保存端做 existence check,此处与之形成写/删对称。
+        // Workshop 不纳入(B1-10 cancellation:无主表的自由文本)。
+        var line = await _db.Queryable<AdoS0LineMaster>()
+            .Where(x => x.Location == location
+                     || x.VLocation == location
+                     || x.Location2 == location
+                     || x.Location3 == location
+                     || x.PickingLocation == location
+                     || x.MidLocation == location)
+            .CountAsync();
+        if (line > 0) return (line, "产线 (LineMaster 的 Location/VLocation/Location2/Location3/PickingLocation/MidLocation)");
+
         return null;
     }