Browse Source

fix(s0): DELETE 前增加下游引用检查

新增 AdoS0ReferenceChecker,集中实现 4 类主数据的下游引用检测:
- Material → RoutingOpDetail / ItemSubstituteDetail / SrmPurchase
- Supplier → SrmPurchase
- Location → LocationShelfMaster / ItemMaster
- Department → EmployeeMaster / WorkCtrMaster

4 个 Controller 的 DeleteAsync 在物理删除前调用 checker,
命中即返回 409 + 业务码 S01006 与具体下游表/计数文案。
SRM 走 SafeCountAsync 容错,schema 不齐时不阻断(独立 BUG 跟踪)。

附带:MfgRoutingOpDetail 保存路径接入 MaterialExistsAsync 父键校验。

修复 BUG-S0-DELETE-NO-REFCHECK,杜绝孤儿数据。
YY968XX 3 weeks ago
parent
commit
3958ac6d5b

+ 19 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -12,10 +12,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 public class AdoS0MfgRoutingOpDetailsController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0MfgRoutingOpDetail> _rep;
+    private readonly AdoS0ReferenceChecker _refChecker;
 
-    public AdoS0MfgRoutingOpDetailsController(SqlSugarRepository<AdoS0MfgRoutingOpDetail> rep)
+    public AdoS0MfgRoutingOpDetailsController(SqlSugarRepository<AdoS0MfgRoutingOpDetail> rep, AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
+        _refChecker = refChecker;
     }
 
     [HttpGet]
@@ -56,6 +58,14 @@ 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}' 不存在于物料主数据");
+
+        if (!await _refChecker.WorkCenterExistsAsync(dto.WorkCenterCode))
+            return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
+                $"工作中心编码 '{dto.WorkCenterCode}' 不存在于工作中心主数据");
+
         var entity = MapToEntity(dto);
         entity.CreatedAt = DateTime.Now;
         await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
@@ -68,6 +78,14 @@ 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}' 不存在于物料主数据");
+
+        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);

+ 8 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0MaterialsController.cs

@@ -14,10 +14,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Sales;
 public class AdoS0MaterialsController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0ItemMaster> _rep;
+    private readonly AdoS0ReferenceChecker _refChecker;
 
-    public AdoS0MaterialsController(SqlSugarRepository<AdoS0ItemMaster> rep)
+    public AdoS0MaterialsController(SqlSugarRepository<AdoS0ItemMaster> rep, AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
+        _refChecker = refChecker;
     }
 
     [HttpGet]
@@ -126,6 +128,11 @@ public class AdoS0MaterialsController : ControllerBase
         var item = await _rep.GetByIdAsync(id);
         if (item == null) return NotFound();
 
+        var refInfo = await _refChecker.MaterialReferencesAsync(item.ItemNum);
+        if (refInfo is { } r)
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
+                $"存在 {r.Count} 条 {r.Table} 引用该物料,无法删除");
+
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }

+ 9 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SuppliersController.cs

@@ -16,13 +16,16 @@ public class AdoS0SuppliersController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0SuppMaster> _rep;
     private readonly SqlSugarRepository<AdoS0LocationMaster> _locRep;
+    private readonly AdoS0ReferenceChecker _refChecker;
 
     public AdoS0SuppliersController(
         SqlSugarRepository<AdoS0SuppMaster> rep,
-        SqlSugarRepository<AdoS0LocationMaster> locRep)
+        SqlSugarRepository<AdoS0LocationMaster> locRep,
+        AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
         _locRep = locRep;
+        _refChecker = refChecker;
     }
 
     [HttpGet]
@@ -189,6 +192,11 @@ public class AdoS0SuppliersController : ControllerBase
         var item = await _rep.GetByIdAsync(id);
         if (item == null) return NotFound();
 
+        var refInfo = await _refChecker.SupplierReferencesAsync(item.Supp);
+        if (refInfo is { } r)
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
+                $"存在 {r.Count} 条 {r.Table} 引用该供应商,无法删除");
+
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }

+ 9 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0DepartmentsController.cs

@@ -14,10 +14,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 public class AdoS0DepartmentsController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _rep;
+    private readonly AdoS0ReferenceChecker _refChecker;
 
-    public AdoS0DepartmentsController(SqlSugarRepository<AdoS0DepartmentMaster> rep)
+    public AdoS0DepartmentsController(SqlSugarRepository<AdoS0DepartmentMaster> rep, AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
+        _refChecker = refChecker;
     }
 
     [HttpGet]
@@ -99,6 +101,12 @@ public class AdoS0DepartmentsController : ControllerBase
     {
         var item = await _rep.GetByIdAsync(id);
         if (item == null) return NotFound();
+
+        var refInfo = await _refChecker.DepartmentReferencesAsync(item.Department);
+        if (refInfo is { } r)
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
+                $"存在 {r.Count} 条 {r.Table} 引用该部门,无法删除");
+
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }

+ 9 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0LocationsController.cs

@@ -14,10 +14,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 public class AdoS0LocationsController : ControllerBase
 {
     private readonly SqlSugarRepository<AdoS0LocationMaster> _rep;
+    private readonly AdoS0ReferenceChecker _refChecker;
 
-    public AdoS0LocationsController(SqlSugarRepository<AdoS0LocationMaster> rep)
+    public AdoS0LocationsController(SqlSugarRepository<AdoS0LocationMaster> rep, AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
+        _refChecker = refChecker;
     }
 
     [HttpGet]
@@ -107,6 +109,12 @@ public class AdoS0LocationsController : ControllerBase
     {
         var item = await _rep.GetByIdAsync(id);
         if (item == null) return NotFound();
+
+        var refInfo = await _refChecker.LocationReferencesAsync(item.Location);
+        if (refInfo is { } r)
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
+                $"存在 {r.Count} 条 {r.Table} 引用该库位,无法删除");
+
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }

+ 152 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -0,0 +1,152 @@
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Sales;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Supply;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// S0 删除前下游引用检查。
+/// 每个主数据主表在 DELETE 前调用对应方法,若存在下游引用则拒绝删除。
+/// 返回 null 表示无引用可安全删除;非空表示 (引用数, 下游表语义描述)。
+/// </summary>
+public sealed class AdoS0ReferenceChecker
+{
+    private readonly ISqlSugarClient _db;
+    private readonly ILogger<AdoS0ReferenceChecker> _logger;
+
+    public AdoS0ReferenceChecker(ISqlSugarClient db, ILogger<AdoS0ReferenceChecker> logger)
+    {
+        _db = db;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 容错包装:对已知 schema 不对齐表(如 srm_purchase)的查询失败时不阻断删除,只记日志。
+    /// 独立问题由 BUG-S0-SRMPURCHASE-SCHEMA-MISMATCH 单独修。
+    /// </summary>
+    private async Task<int> SafeCountAsync<T>(Func<ISugarQueryable<T>> queryFactory, string tag) where T : class, new()
+    {
+        try
+        {
+            return await queryFactory().CountAsync();
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[AdoS0ReferenceChecker] skip {Tag} check due to schema mismatch", tag);
+            return 0;
+        }
+    }
+
+    /// <summary>
+    /// 保存端:物料(ItemMaster)存在性检查。
+    /// 仅校验"目标主表存在该 itemNum",不做 factoryRefId/domain 作用域校验(B2 归口)。
+    /// 空值视为"未填"直接放行,由调用方先做必填校验。
+    /// </summary>
+    public async Task<bool> MaterialExistsAsync(string? itemNum)
+    {
+        if (string.IsNullOrWhiteSpace(itemNum)) return true;
+        return await _db.Queryable<AdoS0ItemMaster>()
+            .Where(x => x.ItemNum == itemNum)
+            .AnyAsync();
+    }
+
+    /// <summary>
+    /// 保存端:工作中心(WorkCtrMaster)存在性检查。
+    /// 注意跨命名:业务键是 WorkCtrMaster.WorkCtr(非 WorkCenterCode)。
+    /// 空值视为"未填"直接放行;作用域(Domain/Factory)一致性归 B2。
+    /// </summary>
+    public async Task<bool> WorkCenterExistsAsync(string? workCenterCode)
+    {
+        if (string.IsNullOrWhiteSpace(workCenterCode)) return true;
+        return await _db.Queryable<AdoS0WorkCtrMaster>()
+            .Where(x => x.WorkCtr == workCenterCode)
+            .AnyAsync();
+    }
+
+    /// <summary>
+    /// 物料(ItemMaster)删除前引用检查。
+    /// 覆盖下游:工艺路线明细 / 物料替代关系 / 货源清单(SRM 采购)。
+    /// </summary>
+    public async Task<(int Count, string Table)?> MaterialReferencesAsync(string? itemNum)
+    {
+        if (string.IsNullOrWhiteSpace(itemNum)) return null;
+
+        var routing = await _db.Queryable<AdoS0MfgRoutingOpDetail>()
+            .Where(x => x.MaterialCode == itemNum)
+            .CountAsync();
+        if (routing > 0) return (routing, "工艺路线明细 (RoutingOpDetail)");
+
+        var sub = await _db.Queryable<AdoS0ItemSubstituteDetail>()
+            .Where(x => x.ItemNum == itemNum || x.SubstituteItem == itemNum)
+            .CountAsync();
+        if (sub > 0) return (sub, "物料替代关系 (ItemSubstituteDetail)");
+
+        var srm = await SafeCountAsync(
+            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.MaterialCode == itemNum),
+            "SrmPurchase.MaterialCode");
+        if (srm > 0) return (srm, "货源清单 (SrmPurchase)");
+
+        return null;
+    }
+
+    /// <summary>
+    /// 供应商(SuppMaster)删除前引用检查。
+    /// 覆盖下游:货源清单(SRM 采购)。
+    /// </summary>
+    public async Task<(int Count, string Table)?> SupplierReferencesAsync(string? supp)
+    {
+        if (string.IsNullOrWhiteSpace(supp)) return null;
+
+        var srm = await SafeCountAsync(
+            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.Supplier == supp || x.SupplierNumber == supp),
+            "SrmPurchase.Supplier");
+        if (srm > 0) return (srm, "货源清单 (SrmPurchase)");
+
+        return null;
+    }
+
+    /// <summary>
+    /// 库位(LocationMaster)删除前引用检查。
+    /// 覆盖下游:货架 / 物料默认库位。
+    /// </summary>
+    public async Task<(int Count, string Table)?> LocationReferencesAsync(string? location)
+    {
+        if (string.IsNullOrWhiteSpace(location)) return null;
+
+        var shelf = await _db.Queryable<AdoS0LocationShelfMaster>()
+            .Where(x => x.Location == location)
+            .CountAsync();
+        if (shelf > 0) return (shelf, "货架 (LocationShelfMaster)");
+
+        var item = await _db.Queryable<AdoS0ItemMaster>()
+            .Where(x => x.Location == location)
+            .CountAsync();
+        if (item > 0) return (item, "物料默认库位 (ItemMaster.Location)");
+
+        return null;
+    }
+
+    /// <summary>
+    /// 部门(DepartmentMaster)删除前引用检查。
+    /// 覆盖下游:员工所属部门 / 工作中心所属部门。
+    /// </summary>
+    public async Task<(int Count, string Table)?> DepartmentReferencesAsync(string? department)
+    {
+        if (string.IsNullOrWhiteSpace(department)) return null;
+
+        var emp = await _db.Queryable<AdoS0EmployeeMaster>()
+            .Where(x => x.Department == department)
+            .CountAsync();
+        if (emp > 0) return (emp, "员工 (EmployeeMaster)");
+
+        var wc = await _db.Queryable<AdoS0WorkCtrMaster>()
+            .Where(x => x.Department == department)
+            .CountAsync();
+        if (wc > 0) return (wc, "工作中心 (WorkCtrMaster)");
+
+        return null;
+    }
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -28,6 +28,7 @@ public class Startup : AppStartup
         services.AddConfigurableOptions<S8ActiveFlowWatchOptions>();
         services.AddScoped<AdoS0ExceptionFilter>();
         services.AddScoped<AdoS0ResultFilter>();
+        services.AddScoped<AdoS0ReferenceChecker>();
         services.Configure<MvcOptions>(options =>
         {
             options.Filters.AddService<AdoS0ExceptionFilter>();