浏览代码

fix(s8): populate module code on exception creation

YY968XX 1 周之前
父节点
当前提交
ab0cca37a2

+ 23 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8SceneCode.cs

@@ -155,11 +155,33 @@ public static class S8ModuleCode
     /// <summary>所有模块码有序列表</summary>
     public static readonly string[] All = { S1, S2, S3, S4, S5, S6, S7 };
 
+    /// <summary>
+    /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:合法 module_code 校验。
+    /// 仅 S1-S7 完全匹配返回 true;空值 / 任何非 S1-S7 字符串(含 legacy 复合 scene)返回 false。
+    /// </summary>
+    public static bool IsValid(string? code) =>
+        !string.IsNullOrWhiteSpace(code) && Array.IndexOf(All, code!) >= 0;
+
+    /// <summary>
+    /// 输入归一化:合法 S1-S7 原样返回,非法(含 legacy 复合 scene 字面量)返回 null。
+    /// 用于建单链路,caller 可链式优先级派生 module_code。
+    /// </summary>
+    public static string? Normalize(string? code) => IsValid(code) ? code : null;
+
+    /// <summary>
+    /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:严格按 single-module scene 派生 module_code。
+    /// 仅当 sceneCode ∈ {S1..S7} 时返回该值;
+    /// 不接受 legacy 复合 scene(S1S7_DELIVERY / S2S6_PRODUCTION / S2S6_QUALITY / S3S5_SUPPLY / S4_PURCHASE)。
+    /// 用于新建异常的 module_code 派生;与 <see cref="FromScene"/> 区别在于不做 legacy 映射。
+    /// </summary>
+    public static string? FromCanonicalScene(string? sceneCode) => Normalize(sceneCode);
+
     /// <summary>
     /// 场景码 → 代表模块码(双轨)。
     ///   - 新 S1-S7 scene:返回自身(<see cref="S8SceneCode.Modules"/> 1:1 自映射);
     ///   - 旧复合 scene:返回代表模块(取 <see cref="S8SceneCode.Modules"/> 首项),保留历史行为;
-    /// 用于建单时回填 module_code,caller 可在拿到此值前显式覆盖。
+    /// 用于历史 process_node 等非 module_code 派生场景;新建异常 module_code 请改用
+    /// <see cref="FromCanonicalScene"/>(严格版,禁止 legacy 映射)。
     /// 未识别 scene 返回 null(caller 自决:留空或兜底)。
     /// </summary>
     public static string? FromScene(string? sceneCode)

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -45,7 +45,8 @@ public sealed class S8RuleHit
 
     /// <summary>
     /// 命中归属模块码(S1-S7)。evaluator 可显式提供以覆盖 scene 默认派生;
-    /// 留 null 时建单方按 scene 取代表模块(S8ModuleCode.FromScene)。
+    /// 留 null 时建单方按严格 S1-S7 派生:hit.SceneCode(仅 S1-S7)→ exception_type.scene_code。
+    /// 不接受 legacy 复合 scene(S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1)。
     /// </summary>
     public string? ModuleCode { get; set; }
 }

+ 83 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -29,6 +29,40 @@ public class S8ManualReportService : ITransient
         return null;
     }
 
+    /// <summary>
+    /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:建单 module_code 派生统一入口。
+    /// 优先级:显式 module → hit module → 单模块 sceneCode(S1-S7)→ exception_type.scene_code。
+    /// 严格 S1-S7 校验,不接受 legacy 复合 scene;最终无法确定时返回 null(caller 决定记日志或拒绝)。
+    /// </summary>
+    private async Task<string?> ResolveModuleCodeAsync(
+        string? explicitModuleCode,
+        string? hitModuleCode,
+        string? sceneCode,
+        string? exceptionTypeCode)
+    {
+        var byExplicit = S8ModuleCode.Normalize(explicitModuleCode);
+        if (byExplicit != null) return byExplicit;
+
+        var byHit = S8ModuleCode.Normalize(hitModuleCode);
+        if (byHit != null) return byHit;
+
+        var byScene = S8ModuleCode.FromCanonicalScene(sceneCode);
+        if (byScene != null) return byScene;
+
+        if (!string.IsNullOrWhiteSpace(exceptionTypeCode))
+        {
+            var typeScene = await _typeRep.AsQueryable().ClearFilter()
+                .Where(t => t.TypeCode == exceptionTypeCode && t.Enabled)
+                .OrderByDescending(t => t.FactoryId)
+                .Select(t => t.SceneCode)
+                .FirstAsync();
+            var byType = S8ModuleCode.FromCanonicalScene(typeScene);
+            if (byType != null) return byType;
+        }
+
+        return null;
+    }
+
     private readonly SqlSugarRepository<AdoS8Exception> _rep;
     private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
     private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
@@ -164,6 +198,20 @@ public class S8ManualReportService : ITransient
         // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
         var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim());
 
+        // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:手工提报 module_code 严格按 S1-S7 派生;
+        // dto 暂不携带显式 module_code,按 scene → exception_type.scene 链路降级。
+        var resolvedModule = await ResolveModuleCodeAsync(
+            explicitModuleCode: null,
+            hitModuleCode: null,
+            sceneCode: dto.SceneCode.Trim(),
+            exceptionTypeCode: inferredType);
+        if (resolvedModule == null)
+        {
+            _logger.LogWarning(
+                "manual_report_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} title={Title}",
+                dto.SceneCode, inferredType, dto.Title);
+        }
+
         var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         {
@@ -182,8 +230,8 @@ public class S8ManualReportService : ITransient
             ResponsibleDeptId = dto.ResponsibleDeptId,
             ReporterId = currentUserId,
             ExceptionTypeCode = inferredType,
-            ModuleCode = S8ModuleCode.FromScene(dto.SceneCode.Trim()),
-            ProcessNodeCode = ResolveProcessNodeCode(dto.SceneCode.Trim(), S8ModuleCode.FromScene(dto.SceneCode.Trim())),
+            ModuleCode = resolvedModule,
+            ProcessNodeCode = ResolveProcessNodeCode(dto.SceneCode.Trim(), resolvedModule),
             CreatedAt = DateTime.Now,
             IsDeleted = false
         };
@@ -232,6 +280,19 @@ public class S8ManualReportService : ITransient
 
         var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
+        // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT,
+        // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。
+        var resolvedModule = await ResolveModuleCodeAsync(
+            explicitModuleCode: null,
+            hitModuleCode: null,
+            sceneCode: S8SceneCode.S2,
+            exceptionTypeCode: "EQUIP_FAULT");
+        if (resolvedModule == null)
+        {
+            _logger.LogWarning(
+                "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}",
+                S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId);
+        }
         var entity = new AdoS8Exception
         {
             // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。
@@ -256,10 +317,9 @@ public class S8ManualReportService : ITransient
             IsDeleted = false,
             // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
             ExceptionTypeCode = "EQUIP_FAULT",
-            // S2 自映射 → S2。看板按 module_code 聚合,留空会导致模块卡空白。
-            ModuleCode = S8ModuleCode.FromScene(S8SceneCode.S2),
+            ModuleCode = resolvedModule,
             // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 与 module 对齐 S1-S7。
-            ProcessNodeCode = ResolveProcessNodeCode(S8SceneCode.S2, S8ModuleCode.FromScene(S8SceneCode.S2)),
+            ProcessNodeCode = ResolveProcessNodeCode(S8SceneCode.S2, resolvedModule),
             // 追溯三件套(自动建单必填口径)。
             SourceRuleId = hit.SourceRuleId,
             SourceDataSourceId = hit.DataSourceId,
@@ -301,6 +361,21 @@ public class S8ManualReportService : ITransient
         if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
             throw new S8BizException("自动建单缺失追溯键");
 
+        var effectiveScene = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode;
+        // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:R2 自动建单 module_code 派生统一走严格 S1-S7 链路;
+        // hit.ModuleCode(evaluator 显式)→ rule/hit.SceneCode(当前 DB 100% S1-S7)→ exception_type.scene_code。
+        var resolvedModule = await ResolveModuleCodeAsync(
+            explicitModuleCode: null,
+            hitModuleCode: hit.ModuleCode,
+            sceneCode: effectiveScene,
+            exceptionTypeCode: hit.ExceptionTypeCode);
+        if (resolvedModule == null)
+        {
+            _logger.LogWarning(
+                "auto_watch_hit_module_code_unresolved sceneCode={SceneCode} hitModule={HitModule} typeCode={TypeCode} ruleCode={RuleCode}",
+                hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode);
+        }
+
         var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         {
@@ -312,7 +387,7 @@ public class S8ManualReportService : ITransient
                 ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
                 : hit.Title,
             Description = null,
-            SceneCode = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode,
+            SceneCode = effectiveScene,
             SourceType = "AUTO_WATCH",
             Status = "NEW",
             Severity = string.IsNullOrWhiteSpace(hit.Severity) ? "MEDIUM" : hit.Severity,
@@ -324,16 +399,9 @@ public class S8ManualReportService : ITransient
             CreatedAt = DateTime.Now,
             IsDeleted = false,
             ExceptionTypeCode = hit.ExceptionTypeCode,
-            // 优先使用 evaluator 显式提供的 module_code,否则按 hit.SceneCode 取代表模块。
-            ModuleCode = !string.IsNullOrWhiteSpace(hit.ModuleCode)
-                ? hit.ModuleCode
-                : S8ModuleCode.FromScene(string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode),
+            ModuleCode = resolvedModule,
             // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 与 module 对齐 S1-S7。
-            ProcessNodeCode = ResolveProcessNodeCode(
-                string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode,
-                !string.IsNullOrWhiteSpace(hit.ModuleCode)
-                    ? hit.ModuleCode
-                    : S8ModuleCode.FromScene(string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode)),
+            ProcessNodeCode = ResolveProcessNodeCode(effectiveScene, resolvedModule),
             SourceRuleId = hit.SourceRuleId,
             SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
             SourcePayload = hit.SourcePayload,