using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing; using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8ExceptionService : ITransient { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _deptRep; private readonly SqlSugarRepository _empRep; private readonly SqlSugarRepository _sysUserRep; public S8ExceptionService( SqlSugarRepository rep, SqlSugarRepository deptRep, SqlSugarRepository empRep, SqlSugarRepository sysUserRep) { _rep = rep; _deptRep = deptRep; _empRep = empRep; _sysUserRep = sysUserRep; } public async Task<(int total, List list)> GetPagedAsync(AdoS8ExceptionQueryDto q) { (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize); var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" }; var includeUnclassified = q.IncludeUnclassified == true; // S8-SLA-TIMEOUT-RUNTIME-1(P3):方法入口取一次 now,供 q.TimeoutFlag 筛选 + 投影写 TimeoutFlag 共用,避免分页前后不一致。 var timeoutNow = DateTime.Now; var query = _rep.Context.Queryable() .LeftJoin((e, sc) => e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode) .LeftJoin((e, sc, wr) => e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode) .Where((e, sc, wr) => e.TenantId == q.TenantId && e.FactoryId == q.FactoryId && !e.IsDeleted) .WhereIF(!includeUnclassified, (e, sc, wr) => !SqlFunc.IsNullOrEmpty(e.ExceptionTypeCode)) .WhereIF(!string.IsNullOrWhiteSpace(q.Status), (e, sc, wr) => e.Status == q.Status) .WhereIF(string.IsNullOrWhiteSpace(q.Status) && q.StatusBucket == "pending", (e, sc, wr) => pendingStatuses.Contains(e.Status)) // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:legacy 兼容 — q.Severity 传 LOW/MEDIUM/HIGH/CRITICAL 时 // 通过 Normalize 映射为 FOLLOW/SERIOUS 再过滤。 .WhereIF(!string.IsNullOrWhiteSpace(q.Severity), (e, sc, wr) => e.Severity == S8SeverityCode.Normalize(q.Severity)) .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), (e, sc, wr) => e.SceneCode == q.SceneCode) .WhereIF(!string.IsNullOrWhiteSpace(q.ModuleCode), (e, sc, wr) => e.ModuleCode == q.ModuleCode) // S8-DASHBOARD-DATA-ALIGN-S1S7-1:看板明细表传 OnlyS1S7Modules=true 时与 KPI 口径对齐; // 异常列表页不传该参数,行为保持不变(仍可见 NULL module 的 legacy 行)。 .WhereIF(q.OnlyS1S7Modules == true, (e, sc, wr) => S8ModuleCode.All.Contains(e.ModuleCode)) .WhereIF(q.DeptId.HasValue, (e, sc, wr) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value) // S8-SLA-TIMEOUT-RUNTIME-1(P3):q.TimeoutFlag 由 timeout_flag 字段筛选切到 sla_deadline + status 在线计算。 // q.TimeoutFlag=true → 当前超时;q.TimeoutFlag=false → 未当前超时(含未配 SLA / 已关闭 / 已恢复)。 .WhereIF(q.TimeoutFlag == true, (e, sc, wr) => e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED") .WhereIF(q.TimeoutFlag == false, (e, sc, wr) => !(e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED")) .WhereIF(q.BeginTime.HasValue, (e, sc, wr) => e.CreatedAt >= q.BeginTime!.Value) .WhereIF(q.EndTime.HasValue, (e, sc, wr) => e.CreatedAt <= q.EndTime!.Value) .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc, wr) => e.ProcessNodeCode == q.ProcessNodeCode) .WhereIF(!string.IsNullOrWhiteSpace(q.RelatedObjectCode), (e, sc, wr) => e.RelatedObjectCode == q.RelatedObjectCode) .WhereIF(q.RecoveredStatus == "RECOVERED", (e, sc, wr) => e.RecoveredAt != null) .WhereIF(q.RecoveredStatus == "ACTIVE", (e, sc, wr) => e.RecoveredAt == null) .WhereIF(!string.IsNullOrWhiteSpace(q.RuleType), (e, sc, wr) => wr.RuleType == q.RuleType) .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!)); var total = await query.CountAsync(); var list = await query .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc) .Select((e, sc, wr) => new AdoS8ExceptionListItemDto { Id = e.Id, FactoryId = e.FactoryId, ExceptionCode = e.ExceptionCode, Title = e.Title, Status = e.Status, Severity = e.Severity, PriorityScore = e.PriorityScore, PriorityLevel = e.PriorityLevel, SceneCode = e.SceneCode, SceneName = sc.SceneName, ModuleCode = e.ModuleCode, ResponsibleDeptId = e.ResponsibleDeptId, OccurrenceDeptId = e.OccurrenceDeptId, AssigneeId = e.AssigneeId, SlaDeadline = e.SlaDeadline, // S8-SLA-TIMEOUT-RUNTIME-1(P3):TimeoutFlag 展示字段 = 在线计算,与 dashboard / monitoring 同口径。 TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED", CreatedAt = e.CreatedAt, ClosedAt = e.ClosedAt, ExceptionTypeCode = e.ExceptionTypeCode, RecoveredAt = e.RecoveredAt, SourceRuleCode = e.SourceRuleCode, SourceObjectType = e.SourceObjectType, SourceObjectId = e.SourceObjectId, DedupKey = e.DedupKey, LastDetectedAt = e.LastDetectedAt, RuleType = wr.RuleType }) .ToPageListAsync(q.Page, q.PageSize); foreach (var r in list) { r.StatusLabel = S8Labels.StatusLabel(r.Status); r.SeverityLabel = S8Labels.SeverityLabel(r.Severity); r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!); } await FillDisplayNamesAsync(list, q.FactoryId); return (total, list); } public async Task GetFilterOptionsAsync(long tenantId, long factoryId) { var scenes = await _rep.Context.Queryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled) .OrderBy(x => x.SortNo) .Select(x => new { value = x.SceneCode, label = x.SceneName }) .ToListAsync(); var departments = await _deptRep.AsQueryable() .Where(x => x.FactoryRefId == factoryId) .OrderBy(x => x.Department) .Take(500) .Select(x => new { value = x.Id, label = x.Descr ?? x.Department }) .ToListAsync(); // S8-EXCEPTION-MODULE-DISPLAY-1:业务展示主口径切到 module_code(S1-S7)。 // scenes 保留兼容期,前端筛选已切到 modules。 var modules = S8ModuleCode.All .Select(code => new { value = code, label = S8ModuleCode.Label(code) }) .ToList(); return new { statuses = S8Labels.StatusOptions(), severities = S8Labels.SeverityOptions(), scenes, modules, departments }; } public async Task GetDetailAsync(long id, long tenantId, long factoryId) { // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。 var timeoutNow = DateTime.Now; var rows = await _rep.Context.Queryable() .LeftJoin((e, sc) => e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode) .LeftJoin((e, sc, wr) => e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode) .Where((e, sc, wr) => e.Id == id && e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted) .Select((e, sc, wr) => new AdoS8ExceptionDetailDto { Id = e.Id, FactoryId = e.FactoryId, ExceptionCode = e.ExceptionCode, Title = e.Title, Description = e.Description, Status = e.Status, Severity = e.Severity, PriorityScore = e.PriorityScore, PriorityLevel = e.PriorityLevel, SceneCode = e.SceneCode, SceneName = sc.SceneName, ModuleCode = e.ModuleCode, SourceType = e.SourceType, OccurrenceDeptId = e.OccurrenceDeptId, ResponsibleDeptId = e.ResponsibleDeptId, ResponsibleGroupId = e.ResponsibleGroupId, AssigneeId = e.AssigneeId, ReporterId = e.ReporterId, SlaDeadline = e.SlaDeadline, // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。 TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED", CreatedAt = e.CreatedAt, ClosedAt = e.ClosedAt, AssignedAt = e.AssignedAt, UpdatedAt = e.UpdatedAt, ActiveFlowInstanceId = e.ActiveFlowInstanceId, ActiveFlowBizType = e.ActiveFlowBizType, VerifierId = e.VerifierId, VerificationAssignedAt = e.VerificationAssignedAt, VerifiedAt = e.VerifiedAt, VerificationResult = e.VerificationResult, VerificationRemark = e.VerificationRemark, SourceRuleId = e.SourceRuleId, RelatedObjectCode = e.RelatedObjectCode, DedupKey = e.DedupKey, LastDetectedAt = e.LastDetectedAt, RecoveredAt = e.RecoveredAt, SourceRuleCode = e.SourceRuleCode, SourceObjectType = e.SourceObjectType, SourceObjectId = e.SourceObjectId, ExceptionTypeCode = e.ExceptionTypeCode, RuleType = wr.RuleType, }) .Take(1) .ToListAsync(); if (rows.Count == 0) return null; var d = rows[0]; d.StatusLabel = S8Labels.StatusLabel(d.Status); d.SeverityLabel = S8Labels.SeverityLabel(d.Severity); d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!); await FillDisplayNamesAsync(new[] { d }, factoryId); return d; } // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName; // 部门查询加 factory_ref_id 二次约束,避免跨 factory 同名 / 同 RecID 错位。 private async Task FillDisplayNamesAsync(IEnumerable rows, long factoryId) { var list = rows.ToList(); if (list.Count == 0) return; var deptIds = list .Select(x => x.ResponsibleDeptId) .Concat(list.Select(x => x.OccurrenceDeptId)) .Where(x => x > 0) .Distinct() .ToList(); var empIds = list .Select(x => x.AssigneeId ?? 0L) .Concat(list.OfType().Select(x => x.ReporterId ?? 0L)) .Concat(list.OfType().Select(x => x.VerifierId ?? 0L)) .Where(x => x > 0) .Distinct() .ToList(); var deptMap = deptIds.Count == 0 ? new Dictionary() : (await _deptRep.AsQueryable() .Where(x => deptIds.Contains(x.Id) && x.FactoryRefId == factoryId) .Select(x => new { x.Id, Name = x.Descr ?? x.Department }) .ToListAsync()) .ToDictionary(x => x.Id, x => x.Name); // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位(797403760988229 ≠ 系统租户), // 走全局 multi-tenant filter 会全过滤为空 → 详情页处理人/检验人姓名显示数字 ID。 // 与 S8MasterDataAdapter.GetEmployeesAsync / S8TaskFlowService.GetEmployeeSysUserIdAsync 同口径, // 局部 ClearFilter + employee.Id 主键集合作为安全边界,无跨租户泄漏。 var empMap = empIds.Count == 0 ? new Dictionary() : (await _empRep.AsQueryable().ClearFilter() .Where(x => empIds.Contains(x.Id)) .Select(x => new { x.Id, Name = x.Name ?? x.Employee }) .ToListAsync()) .ToDictionary(x => x.Id, x => x.Name); // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):ReporterId 新协议 = EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。 // 水合优先级:EmployeeMaster.Name → fallback SysUser.RealName/Account(兼容旧数据 reporter=SysUser.Id)。 // 旧注释 “ReporterId 现在记录 sysUser.Id” 已失效,由本批协议替代。 var reporterUserIds = list.OfType() .Select(x => x.ReporterId ?? 0L) .Where(x => x > 0) .Distinct() .ToList(); var reporterUserMap = reporterUserIds.Count == 0 ? new Dictionary() : (await _sysUserRep.AsQueryable().ClearFilter() .Where(u => reporterUserIds.Contains(u.Id)) .Select(u => new { u.Id, u.RealName, u.Account }) .ToListAsync()) .ToDictionary( u => u.Id, u => !string.IsNullOrWhiteSpace(u.RealName) ? u.RealName : u.Account); foreach (var row in list) { row.ResponsibleDeptName = ResolveDeptName(row.ResponsibleDeptId, deptMap); row.OccurrenceDeptName = ResolveDeptName(row.OccurrenceDeptId, deptMap); row.AssigneeName = row.AssigneeId.HasValue ? empMap.GetValueOrDefault(row.AssigneeId.Value) : null; if (row is AdoS8ExceptionDetailDto detail) { // S8-REPORTER-IDSPACE-FIX-1:empMap(EmployeeMaster.RecID)优先,reporterUserMap(SysUser.Id)兜底兼容旧数据。 detail.ReporterName = detail.ReporterId.HasValue ? (empMap.GetValueOrDefault(detail.ReporterId.Value) ?? reporterUserMap.GetValueOrDefault(detail.ReporterId.Value)) : null; detail.VerifierName = detail.VerifierId.HasValue ? empMap.GetValueOrDefault(detail.VerifierId.Value) : null; } } } // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):dept fallback —— 0/缺失=未归属,查不到名=部门ID:{id}。 private static string? ResolveDeptName(long deptId, Dictionary deptMap) { if (deptId <= 0) return "未归属"; return deptMap.TryGetValue(deptId, out var name) && !string.IsNullOrWhiteSpace(name) ? name : $"部门ID:{deptId}"; } /// /// S2-EW:debug-only 软删 RelatedObjectCode 以 "TEST_G09_" 开头的异常。 /// 调用方仅限 ,由其 _debugEndpointEnabled 守门。 /// 实现纪律: /// - 仅 SET IsDeleted=true,不动业务字段(status/closed_at/source_rule_id 等)。 /// - 前缀字面量硬编码,不接受外部 prefix;防止前缀注入。 /// - 不联动 timeline / decision / evidence;业务读路径以父 IsDeleted 屏蔽。 /// public async Task SoftDeleteTestPrefixAsync(long tenantId, long factoryId) { const string prefix = "TEST_G09_"; var affected = await _rep.AsUpdateable() .SetColumns(e => new AdoS8Exception { IsDeleted = true }) .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted && e.RelatedObjectCode != null && e.RelatedObjectCode.StartsWith(prefix)) .ExecuteCommandAsync(); System.Diagnostics.Trace.TraceWarning( $"[S2-EW] soft-deleted {affected} TEST_G09_ exceptions, tenant={tenantId} factory={factoryId}"); return affected; } }