Просмотр исходного кода

feat(s8): resolve departments for auto watch exceptions

YY968XX 1 месяц назад
Родитель
Сommit
ddd39a8049
1 измененных файлов с 196 добавлено и 66 удалено
  1. 196 66
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

+ 196 - 66
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -75,6 +75,8 @@ public class S8ManualReportService : ITransient
     private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
     private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
     private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
+    // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门 resolver 需读取 watch_rule.params_json 里的默认部门字段。
+    private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
     private readonly UserManager _userManager;
     private readonly FlowEngineService _flowEngine;
     private readonly ILogger<S8ManualReportService> _logger;
@@ -88,6 +90,7 @@ public class S8ManualReportService : ITransient
         SqlSugarRepository<AdoS0LineMaster> lineRep,
         SqlSugarRepository<AdoS8ExceptionType> typeRep,
         SqlSugarRepository<AdoS0EmployeeMaster> empRep,
+        SqlSugarRepository<AdoS8WatchRule> ruleRep,
         UserManager userManager,
         FlowEngineService flowEngine,
         ILogger<S8ManualReportService> logger)
@@ -100,16 +103,31 @@ public class S8ManualReportService : ITransient
         _lineRep = lineRep;
         _typeRep = typeRep;
         _empRep = empRep;
+        _ruleRep = ruleRep;
         _userManager = userManager;
         _flowEngine = flowEngine;
         _logger = logger;
     }
 
-    // S8-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):自动建单将写入 dept=0 时打 warning,行为不变。
-    // 触发:raw 部门字段为 null 或 <=0。两条自动建单路径(CreateFromWatchAsync / CreateFromHitAsync)
-    // 当前都用 `?? 0` 兜底;本助手只观测、不阻断、不替换值。部门派生器属 P2,不在本批。
-    private void WarnIfAutoWatchZeroDept(
+    // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门解析顺序 hit → watch_rule.params_json default → 未归属。
+    // 单字段独立解析(occurrence / responsible 各自走优先级)。任一字段最终为 null → 调用方按"throw 让 scheduler 跳过"处理。
+    // 不允许写 0 作为最终值;不硬编码 1=质量部 / 2=生产部;不猜测业务对象部门派生(待后续增强)。
+    // 协议常量:未归属部门 codename = D-UNASSIGNED;按 factory_ref_id 唯一存在;本批不创建该基线,缺失时 resolver 返回 null。
+    private const string UnassignedDepartmentCode = "D-UNASSIGNED";
+
+    private sealed class AutoWatchDeptResolution
+    {
+        public long? OccurrenceDeptId { get; set; }
+        public long? ResponsibleDeptId { get; set; }
+        public string OccurrenceSource { get; set; } = "failed";
+        public string ResponsibleSource { get; set; } = "failed";
+    }
+
+    private async Task<AutoWatchDeptResolution> ResolveAutoWatchDepartmentsAsync(
         string path,
+        long factoryId,
+        long? hitOccurrenceDeptId,
+        long? hitResponsibleDeptId,
         long? ruleId,
         string? ruleCode,
         string? moduleCode,
@@ -118,21 +136,111 @@ public class S8ManualReportService : ITransient
         string? sourceObjectType,
         string? sourceObjectId,
         string? relatedObjectCode,
-        string? dedupKey,
-        long? rawOccurrenceDeptId,
-        long? rawResponsibleDeptId,
-        long finalOccurrenceDeptId,
-        long finalResponsibleDeptId,
-        long tenantId,
-        long factoryId,
-        string? exceptionCode)
+        string? dedupKey)
+    {
+        var result = new AutoWatchDeptResolution();
+
+        // 1) hit dept 优先
+        var occOk = await ValidateDeptForFactoryAsync(hitOccurrenceDeptId, factoryId);
+        var respOk = await ValidateDeptForFactoryAsync(hitResponsibleDeptId, factoryId);
+        if (occOk) { result.OccurrenceDeptId = hitOccurrenceDeptId; result.OccurrenceSource = "hit"; }
+        if (respOk) { result.ResponsibleDeptId = hitResponsibleDeptId; result.ResponsibleSource = "hit"; }
+
+        // 2) watch_rule.params_json 默认部门
+        if ((!occOk || !respOk) && ruleId.HasValue && ruleId.Value > 0)
+        {
+            var paramsJson = await _ruleRep.AsQueryable()
+                .Where(x => x.Id == ruleId.Value)
+                .Select(x => x.ParamsJson)
+                .FirstAsync();
+            var (paramsOcc, paramsResp) = ParseParamsDefaultDepts(paramsJson);
+            if (!occOk && paramsOcc.HasValue)
+            {
+                if (await ValidateDeptForFactoryAsync(paramsOcc, factoryId))
+                {
+                    result.OccurrenceDeptId = paramsOcc;
+                    result.OccurrenceSource = "watch_rule_params";
+                }
+                else
+                {
+                    _logger.LogWarning(
+                        "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultOccurrenceDeptId value={Value} factoryId={FactoryId}",
+                        path, ruleId, ruleCode, paramsOcc, factoryId);
+                }
+            }
+            if (!respOk && paramsResp.HasValue)
+            {
+                if (await ValidateDeptForFactoryAsync(paramsResp, factoryId))
+                {
+                    result.ResponsibleDeptId = paramsResp;
+                    result.ResponsibleSource = "watch_rule_params";
+                }
+                else
+                {
+                    _logger.LogWarning(
+                        "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultResponsibleDeptId value={Value} factoryId={FactoryId}",
+                        path, ruleId, ruleCode, paramsResp, factoryId);
+                }
+            }
+        }
+
+        // 3) 未归属部门 fallback
+        long? unassignedId = null;
+        if (result.OccurrenceDeptId == null || result.ResponsibleDeptId == null)
+        {
+            unassignedId = await _deptRep.AsQueryable().ClearFilter()
+                .Where(x => x.Department == UnassignedDepartmentCode && x.FactoryRefId == factoryId && x.IsActive)
+                .Select(x => (long?)x.Id)
+                .FirstAsync();
+            if (result.OccurrenceDeptId == null && unassignedId.HasValue)
+            {
+                result.OccurrenceDeptId = unassignedId;
+                result.OccurrenceSource = "unassigned";
+                _logger.LogWarning(
+                    "s8_auto_watch_dept_unassigned_fallback path={Path} ruleId={RuleId} ruleCode={RuleCode} field=occurrence factoryId={FactoryId} unassignedDeptId={UnassignedId} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey}",
+                    path, ruleId, ruleCode, factoryId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey);
+            }
+            if (result.ResponsibleDeptId == null && unassignedId.HasValue)
+            {
+                result.ResponsibleDeptId = unassignedId;
+                result.ResponsibleSource = "unassigned";
+                _logger.LogWarning(
+                    "s8_auto_watch_dept_unassigned_fallback path={Path} ruleId={RuleId} ruleCode={RuleCode} field=responsible factoryId={FactoryId} unassignedDeptId={UnassignedId} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey}",
+                    path, ruleId, ruleCode, factoryId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey);
+            }
+        }
+
+        return result;
+    }
+
+    private async Task<bool> ValidateDeptForFactoryAsync(long? deptId, long factoryId)
+    {
+        if (!deptId.HasValue || deptId.Value <= 0 || factoryId <= 0) return false;
+        return await _deptRep.AsQueryable().ClearFilter()
+            .Where(x => x.Id == deptId.Value && x.FactoryRefId == factoryId && x.IsActive)
+            .AnyAsync();
+    }
+
+    private static (long? Occurrence, long? Responsible) ParseParamsDefaultDepts(string? paramsJson)
+    {
+        if (string.IsNullOrWhiteSpace(paramsJson)) return (null, null);
+        try
+        {
+            using var doc = System.Text.Json.JsonDocument.Parse(paramsJson);
+            long? occ = ReadJsonLong(doc.RootElement, "defaultOccurrenceDeptId");
+            long? resp = ReadJsonLong(doc.RootElement, "defaultResponsibleDeptId");
+            return (occ, resp);
+        }
+        catch (System.Text.Json.JsonException) { return (null, null); }
+    }
+
+    private static long? ReadJsonLong(System.Text.Json.JsonElement root, string property)
     {
-        var occMissing = !rawOccurrenceDeptId.HasValue || rawOccurrenceDeptId.Value <= 0;
-        var respMissing = !rawResponsibleDeptId.HasValue || rawResponsibleDeptId.Value <= 0;
-        if (!occMissing && !respMissing) return;
-        _logger.LogWarning(
-            "s8_auto_watch_zero_dept_detected path={Path} ruleId={RuleId} ruleCode={RuleCode} moduleCode={ModuleCode} sceneCode={SceneCode} exceptionTypeCode={ExceptionTypeCode} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey} rawOccurrenceDeptId={RawOccDept} rawResponsibleDeptId={RawRespDept} finalOccurrenceDeptId={FinalOccDept} finalResponsibleDeptId={FinalRespDept} tenantId={TenantId} factoryId={FactoryId} exceptionCode={ExceptionCode}",
-            path, ruleId, ruleCode, moduleCode, sceneCode, exceptionTypeCode, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey, rawOccurrenceDeptId, rawResponsibleDeptId, finalOccurrenceDeptId, finalResponsibleDeptId, tenantId, factoryId, exceptionCode);
+        if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
+        if (!root.TryGetProperty(property, out var el)) return null;
+        if (el.ValueKind == System.Text.Json.JsonValueKind.Number && el.TryGetInt64(out var v)) return v;
+        if (el.ValueKind == System.Text.Json.JsonValueKind.String && long.TryParse(el.GetString(), out var sv)) return sv;
+        return null;
     }
 
     // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):把当前登录 SysUser.Id 反查 EmployeeMaster.RecID。
@@ -421,11 +529,37 @@ public class S8ManualReportService : ITransient
                 "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}",
                 S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId);
         }
+        // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。
+        const long fixedTenantId = 1;
+        const long fixedFactoryId = 1;
+        var deptResolution = await ResolveAutoWatchDepartmentsAsync(
+            path: nameof(CreateFromWatchAsync),
+            factoryId: fixedFactoryId,
+            hitOccurrenceDeptId: hit.OccurrenceDeptId,
+            hitResponsibleDeptId: hit.ResponsibleDeptId,
+            ruleId: hit.SourceRuleId,
+            ruleCode: hit.SourceRuleCode,
+            moduleCode: resolvedModule,
+            sceneCode: S8SceneCode.S2,
+            exceptionTypeCode: "EQUIP_FAULT",
+            sourceObjectType: null,
+            sourceObjectId: null,
+            relatedObjectCode: hit.RelatedObjectCode,
+            dedupKey: null);
+        if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue)
+        {
+            _logger.LogWarning(
+                "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occSource={OccSource} respSource={RespSource} relatedObjectCode={RelatedObjectCode}",
+                nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,
+                deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.RelatedObjectCode);
+            throw new S8BizException($"自动建单部门解析失败:缺少未归属部门基线(factory_ref_id={fixedFactoryId}, codename={UnassignedDepartmentCode})");
+        }
+
         var entity = new AdoS8Exception
         {
             // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。
-            TenantId = 1,
-            FactoryId = 1,
+            TenantId = fixedTenantId,
+            FactoryId = fixedFactoryId,
             ExceptionCode = code,
             Title = title,
             Description = null,
@@ -436,10 +570,9 @@ public class S8ManualReportService : ITransient
             Severity = S8SeverityCode.Normalize(hit.Severity),
             PriorityScore = 0,
             PriorityLevel = "P3",
-            // 首版兜底口径:Hit 未提供部门时置 0 仅为保证“能建成标准异常单并进入主链”,
-            // 不是最终业务部门语义;后续需由上游查询结果提供,或在专项任务中补口径。
-            OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
-            ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
+            // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
+            OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
+            ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
             ReporterId = null,
             CreatedAt = DateTime.Now,
             IsDeleted = false,
@@ -456,25 +589,10 @@ public class S8ManualReportService : ITransient
             RelatedObjectCode = hit.RelatedObjectCode
         };
 
-        // S8-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):raw dept 缺失时打 warning,行为不变。
-        WarnIfAutoWatchZeroDept(
-            path: nameof(CreateFromWatchAsync),
-            ruleId: hit.SourceRuleId,
-            ruleCode: null,
-            moduleCode: resolvedModule,
-            sceneCode: S8SceneCode.S2,
-            exceptionTypeCode: "EQUIP_FAULT",
-            sourceObjectType: null,
-            sourceObjectId: null,
-            relatedObjectCode: hit.RelatedObjectCode,
-            dedupKey: null,
-            rawOccurrenceDeptId: hit.OccurrenceDeptId,
-            rawResponsibleDeptId: hit.ResponsibleDeptId,
-            finalOccurrenceDeptId: entity.OccurrenceDeptId,
-            finalResponsibleDeptId: entity.ResponsibleDeptId,
-            tenantId: entity.TenantId,
-            factoryId: entity.FactoryId,
-            exceptionCode: entity.ExceptionCode);
+        _logger.LogInformation(
+            "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource}",
+            nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,
+            entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource);
 
         await _rep.AsTenant().UseTranAsync(async () =>
         {
@@ -525,12 +643,38 @@ public class S8ManualReportService : ITransient
                 hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode);
         }
 
+        // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。
+        const long fixedTenantId = 1;
+        const long fixedFactoryId = 1;
+        var deptResolution = await ResolveAutoWatchDepartmentsAsync(
+            path: nameof(CreateFromHitAsync),
+            factoryId: fixedFactoryId,
+            hitOccurrenceDeptId: hit.OccurrenceDeptId,
+            hitResponsibleDeptId: hit.ResponsibleDeptId,
+            ruleId: hit.SourceRuleId,
+            ruleCode: hit.SourceRuleCode,
+            moduleCode: resolvedModule,
+            sceneCode: effectiveScene,
+            exceptionTypeCode: hit.ExceptionTypeCode,
+            sourceObjectType: hit.SourceObjectType,
+            sourceObjectId: hit.SourceObjectId,
+            relatedObjectCode: hit.RelatedObjectCode,
+            dedupKey: hit.DedupKey);
+        if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue)
+        {
+            _logger.LogWarning(
+                "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occSource={OccSource} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}",
+                nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,
+                deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey);
+            throw new S8BizException($"自动建单部门解析失败:缺少未归属部门基线(factory_ref_id={fixedFactoryId}, codename={UnassignedDepartmentCode})");
+        }
+
         var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         {
             // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
-            TenantId = 1,
-            FactoryId = 1,
+            TenantId = fixedTenantId,
+            FactoryId = fixedFactoryId,
             ExceptionCode = code,
             Title = string.IsNullOrWhiteSpace(hit.Title)
                 ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
@@ -542,8 +686,9 @@ public class S8ManualReportService : ITransient
             Severity = S8SeverityCode.Normalize(hit.Severity),
             PriorityScore = 0,
             PriorityLevel = "P3",
-            OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
-            ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
+            // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
+            OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
+            ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
             ReporterId = null,
             CreatedAt = DateTime.Now,
             IsDeleted = false,
@@ -565,25 +710,10 @@ public class S8ManualReportService : ITransient
             SourceObjectId = hit.SourceObjectId
         };
 
-        // S8-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):raw dept 缺失时打 warning,行为不变。
-        WarnIfAutoWatchZeroDept(
-            path: nameof(CreateFromHitAsync),
-            ruleId: hit.SourceRuleId,
-            ruleCode: hit.SourceRuleCode,
-            moduleCode: resolvedModule,
-            sceneCode: effectiveScene,
-            exceptionTypeCode: hit.ExceptionTypeCode,
-            sourceObjectType: hit.SourceObjectType,
-            sourceObjectId: hit.SourceObjectId,
-            relatedObjectCode: hit.RelatedObjectCode,
-            dedupKey: hit.DedupKey,
-            rawOccurrenceDeptId: hit.OccurrenceDeptId,
-            rawResponsibleDeptId: hit.ResponsibleDeptId,
-            finalOccurrenceDeptId: entity.OccurrenceDeptId,
-            finalResponsibleDeptId: entity.ResponsibleDeptId,
-            tenantId: entity.TenantId,
-            factoryId: entity.FactoryId,
-            exceptionCode: entity.ExceptionCode);
+        _logger.LogInformation(
+            "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}",
+            nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,
+            entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey);
 
         await _rep.AsTenant().UseTranAsync(async () =>
         {