S8ExceptionService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
  3. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. public class S8ExceptionService : ITransient
  9. {
  10. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  11. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  12. private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
  13. private readonly SqlSugarRepository<SysUser> _sysUserRep;
  14. public S8ExceptionService(
  15. SqlSugarRepository<AdoS8Exception> rep,
  16. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  17. SqlSugarRepository<AdoS0EmployeeMaster> empRep,
  18. SqlSugarRepository<SysUser> sysUserRep)
  19. {
  20. _rep = rep;
  21. _deptRep = deptRep;
  22. _empRep = empRep;
  23. _sysUserRep = sysUserRep;
  24. }
  25. public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
  26. {
  27. (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
  28. var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" };
  29. var includeUnclassified = q.IncludeUnclassified == true;
  30. // S8-SLA-TIMEOUT-RUNTIME-1(P3):方法入口取一次 now,供 q.TimeoutFlag 筛选 + 投影写 TimeoutFlag 共用,避免分页前后不一致。
  31. var timeoutNow = DateTime.Now;
  32. var query = _rep.Context.Queryable<AdoS8Exception>()
  33. .LeftJoin<AdoS8SceneConfig>((e, sc) =>
  34. e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
  35. .LeftJoin<AdoS8WatchRule>((e, sc, wr) =>
  36. e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode)
  37. .Where((e, sc, wr) => e.TenantId == q.TenantId && e.FactoryId == q.FactoryId && !e.IsDeleted)
  38. .WhereIF(!includeUnclassified, (e, sc, wr) => !SqlFunc.IsNullOrEmpty(e.ExceptionTypeCode))
  39. .WhereIF(!string.IsNullOrWhiteSpace(q.Status), (e, sc, wr) => e.Status == q.Status)
  40. .WhereIF(string.IsNullOrWhiteSpace(q.Status) && q.StatusBucket == "pending",
  41. (e, sc, wr) => pendingStatuses.Contains(e.Status))
  42. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:legacy 兼容 — q.Severity 传 LOW/MEDIUM/HIGH/CRITICAL 时
  43. // 通过 Normalize 映射为 FOLLOW/SERIOUS 再过滤。
  44. .WhereIF(!string.IsNullOrWhiteSpace(q.Severity),
  45. (e, sc, wr) => e.Severity == S8SeverityCode.Normalize(q.Severity))
  46. .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), (e, sc, wr) => e.SceneCode == q.SceneCode)
  47. .WhereIF(!string.IsNullOrWhiteSpace(q.ModuleCode), (e, sc, wr) => e.ModuleCode == q.ModuleCode)
  48. // S8-DASHBOARD-DATA-ALIGN-S1S7-1:看板明细表传 OnlyS1S7Modules=true 时与 KPI 口径对齐;
  49. // 异常列表页不传该参数,行为保持不变(仍可见 NULL module 的 legacy 行)。
  50. .WhereIF(q.OnlyS1S7Modules == true, (e, sc, wr) => S8ModuleCode.All.Contains(e.ModuleCode))
  51. .WhereIF(q.DeptId.HasValue, (e, sc, wr) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value)
  52. // S8-SLA-TIMEOUT-RUNTIME-1(P3):q.TimeoutFlag 由 timeout_flag 字段筛选切到 sla_deadline + status 在线计算。
  53. // q.TimeoutFlag=true → 当前超时;q.TimeoutFlag=false → 未当前超时(含未配 SLA / 已关闭 / 已恢复)。
  54. .WhereIF(q.TimeoutFlag == true, (e, sc, wr) => e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED")
  55. .WhereIF(q.TimeoutFlag == false, (e, sc, wr) => !(e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED"))
  56. .WhereIF(q.BeginTime.HasValue, (e, sc, wr) => e.CreatedAt >= q.BeginTime!.Value)
  57. .WhereIF(q.EndTime.HasValue, (e, sc, wr) => e.CreatedAt <= q.EndTime!.Value)
  58. .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc, wr) => e.ProcessNodeCode == q.ProcessNodeCode)
  59. .WhereIF(!string.IsNullOrWhiteSpace(q.RelatedObjectCode), (e, sc, wr) => e.RelatedObjectCode == q.RelatedObjectCode)
  60. .WhereIF(!string.IsNullOrWhiteSpace(q.OrderFlowCode), (e, sc, wr) => e.OrderFlowCode == q.OrderFlowCode)
  61. .WhereIF(!string.IsNullOrWhiteSpace(q.StageCode), (e, sc, wr) => e.StageCode == q.StageCode)
  62. .WhereIF(!string.IsNullOrWhiteSpace(q.RuleMechanism), (e, sc, wr) => e.RuleMechanism == q.RuleMechanism)
  63. .WhereIF(q.RecoveredStatus == "RECOVERED", (e, sc, wr) => e.RecoveredAt != null)
  64. .WhereIF(q.RecoveredStatus == "ACTIVE", (e, sc, wr) => e.RecoveredAt == null)
  65. .WhereIF(!string.IsNullOrWhiteSpace(q.RuleType), (e, sc, wr) => wr.RuleType == q.RuleType)
  66. .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
  67. (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
  68. var total = await query.CountAsync();
  69. var list = await query
  70. .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
  71. .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
  72. {
  73. Id = e.Id,
  74. FactoryId = e.FactoryId,
  75. ExceptionCode = e.ExceptionCode,
  76. Title = e.Title,
  77. Status = e.Status,
  78. Severity = e.Severity,
  79. PriorityScore = e.PriorityScore,
  80. PriorityLevel = e.PriorityLevel,
  81. SceneCode = e.SceneCode,
  82. SceneName = sc.SceneName,
  83. ModuleCode = e.ModuleCode,
  84. ResponsibleDeptId = e.ResponsibleDeptId,
  85. OccurrenceDeptId = e.OccurrenceDeptId,
  86. AssigneeId = e.AssigneeId,
  87. SlaDeadline = e.SlaDeadline,
  88. // S8-SLA-TIMEOUT-RUNTIME-1(P3):TimeoutFlag 展示字段 = 在线计算,与 dashboard / monitoring 同口径。
  89. TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
  90. CreatedAt = e.CreatedAt,
  91. ClosedAt = e.ClosedAt,
  92. ExceptionTypeCode = e.ExceptionTypeCode,
  93. RecoveredAt = e.RecoveredAt,
  94. SourceRuleCode = e.SourceRuleCode,
  95. SourceObjectType = e.SourceObjectType,
  96. SourceObjectId = e.SourceObjectId,
  97. DedupKey = e.DedupKey,
  98. LastDetectedAt = e.LastDetectedAt,
  99. RuleType = wr.RuleType,
  100. RelatedObjectCode = e.RelatedObjectCode,
  101. OrderFlowCode = e.OrderFlowCode,
  102. StageCode = e.StageCode,
  103. RuleMechanism = e.RuleMechanism,
  104. })
  105. .ToPageListAsync(q.Page, q.PageSize);
  106. foreach (var r in list)
  107. {
  108. r.StatusLabel = S8Labels.StatusLabel(r.Status);
  109. r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
  110. r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
  111. }
  112. await FillDisplayNamesAsync(list, q.FactoryId);
  113. return (total, list);
  114. }
  115. public async Task<object> GetFilterOptionsAsync(long tenantId, long factoryId)
  116. {
  117. var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
  118. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
  119. .OrderBy(x => x.SortNo)
  120. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  121. .ToListAsync();
  122. var departments = await _deptRep.AsQueryable()
  123. .Where(x => x.FactoryRefId == factoryId)
  124. .OrderBy(x => x.Department)
  125. .Take(500)
  126. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  127. .ToListAsync();
  128. // S8-EXCEPTION-MODULE-DISPLAY-1:业务展示主口径切到 module_code(S1-S7)。
  129. // scenes 保留兼容期,前端筛选已切到 modules。
  130. var modules = S8ModuleCode.All
  131. .Select(code => new { value = code, label = S8ModuleCode.Label(code) })
  132. .ToList();
  133. return new
  134. {
  135. statuses = S8Labels.StatusOptions(),
  136. severities = S8Labels.SeverityOptions(),
  137. scenes,
  138. modules,
  139. departments
  140. };
  141. }
  142. public async Task<AdoS8ExceptionDetailDto?> GetDetailAsync(long id, long tenantId, long factoryId)
  143. {
  144. // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。
  145. var timeoutNow = DateTime.Now;
  146. var rows = await _rep.Context.Queryable<AdoS8Exception>()
  147. .LeftJoin<AdoS8SceneConfig>((e, sc) =>
  148. e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
  149. .LeftJoin<AdoS8WatchRule>((e, sc, wr) =>
  150. e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode)
  151. .Where((e, sc, wr) => e.Id == id && e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  152. .Select((e, sc, wr) => new AdoS8ExceptionDetailDto
  153. {
  154. Id = e.Id,
  155. FactoryId = e.FactoryId,
  156. ExceptionCode = e.ExceptionCode,
  157. Title = e.Title,
  158. Description = e.Description,
  159. Status = e.Status,
  160. Severity = e.Severity,
  161. PriorityScore = e.PriorityScore,
  162. PriorityLevel = e.PriorityLevel,
  163. SceneCode = e.SceneCode,
  164. SceneName = sc.SceneName,
  165. ModuleCode = e.ModuleCode,
  166. SourceType = e.SourceType,
  167. OccurrenceDeptId = e.OccurrenceDeptId,
  168. ResponsibleDeptId = e.ResponsibleDeptId,
  169. ResponsibleGroupId = e.ResponsibleGroupId,
  170. AssigneeId = e.AssigneeId,
  171. ReporterId = e.ReporterId,
  172. SlaDeadline = e.SlaDeadline,
  173. // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。
  174. TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
  175. CreatedAt = e.CreatedAt,
  176. ClosedAt = e.ClosedAt,
  177. AssignedAt = e.AssignedAt,
  178. UpdatedAt = e.UpdatedAt,
  179. ActiveFlowInstanceId = e.ActiveFlowInstanceId,
  180. ActiveFlowBizType = e.ActiveFlowBizType,
  181. VerifierId = e.VerifierId,
  182. VerificationAssignedAt = e.VerificationAssignedAt,
  183. VerifiedAt = e.VerifiedAt,
  184. VerificationResult = e.VerificationResult,
  185. VerificationRemark = e.VerificationRemark,
  186. SourceRuleId = e.SourceRuleId,
  187. RelatedObjectCode = e.RelatedObjectCode,
  188. DedupKey = e.DedupKey,
  189. LastDetectedAt = e.LastDetectedAt,
  190. RecoveredAt = e.RecoveredAt,
  191. SourceRuleCode = e.SourceRuleCode,
  192. SourceObjectType = e.SourceObjectType,
  193. SourceObjectId = e.SourceObjectId,
  194. ExceptionTypeCode = e.ExceptionTypeCode,
  195. RuleType = wr.RuleType,
  196. OrderFlowCode = e.OrderFlowCode,
  197. StageCode = e.StageCode,
  198. RuleMechanism = e.RuleMechanism,
  199. })
  200. .Take(1)
  201. .ToListAsync();
  202. if (rows.Count == 0) return null;
  203. var d = rows[0];
  204. d.StatusLabel = S8Labels.StatusLabel(d.Status);
  205. d.SeverityLabel = S8Labels.SeverityLabel(d.Severity);
  206. d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!);
  207. await FillDisplayNamesAsync(new[] { d }, factoryId);
  208. return d;
  209. }
  210. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName;
  211. // 部门查询加 factory_ref_id 二次约束,避免跨 factory 同名 / 同 RecID 错位。
  212. private async Task FillDisplayNamesAsync(IEnumerable<AdoS8ExceptionListItemDto> rows, long factoryId)
  213. {
  214. var list = rows.ToList();
  215. if (list.Count == 0) return;
  216. var deptIds = list
  217. .Select(x => x.ResponsibleDeptId)
  218. .Concat(list.Select(x => x.OccurrenceDeptId))
  219. .Where(x => x > 0)
  220. .Distinct()
  221. .ToList();
  222. var empIds = list
  223. .Select(x => x.AssigneeId ?? 0L)
  224. .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
  225. .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.VerifierId ?? 0L))
  226. .Where(x => x > 0)
  227. .Distinct()
  228. .ToList();
  229. var deptMap = deptIds.Count == 0
  230. ? new Dictionary<long, string>()
  231. : (await _deptRep.AsQueryable()
  232. .Where(x => deptIds.Contains(x.Id) && x.FactoryRefId == factoryId)
  233. .Select(x => new { x.Id, Name = x.Descr ?? x.Department })
  234. .ToListAsync())
  235. .ToDictionary(x => x.Id, x => x.Name);
  236. // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位(797403760988229 ≠ 系统租户),
  237. // 走全局 multi-tenant filter 会全过滤为空 → 详情页处理人/检验人姓名显示数字 ID。
  238. // 与 S8MasterDataAdapter.GetEmployeesAsync / S8TaskFlowService.GetEmployeeSysUserIdAsync 同口径,
  239. // 局部 ClearFilter + employee.Id 主键集合作为安全边界,无跨租户泄漏。
  240. var empMap = empIds.Count == 0
  241. ? new Dictionary<long, string>()
  242. : (await _empRep.AsQueryable().ClearFilter()
  243. .Where(x => empIds.Contains(x.Id))
  244. .Select(x => new { x.Id, Name = x.Name ?? x.Employee })
  245. .ToListAsync())
  246. .ToDictionary(x => x.Id, x => x.Name);
  247. // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):ReporterId 新协议 = EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
  248. // 水合优先级:EmployeeMaster.Name → fallback SysUser.RealName/Account(兼容旧数据 reporter=SysUser.Id)。
  249. // 旧注释 “ReporterId 现在记录 sysUser.Id” 已失效,由本批协议替代。
  250. var reporterUserIds = list.OfType<AdoS8ExceptionDetailDto>()
  251. .Select(x => x.ReporterId ?? 0L)
  252. .Where(x => x > 0)
  253. .Distinct()
  254. .ToList();
  255. var reporterUserMap = reporterUserIds.Count == 0
  256. ? new Dictionary<long, string>()
  257. : (await _sysUserRep.AsQueryable().ClearFilter()
  258. .Where(u => reporterUserIds.Contains(u.Id))
  259. .Select(u => new { u.Id, u.RealName, u.Account })
  260. .ToListAsync())
  261. .ToDictionary(
  262. u => u.Id,
  263. u => !string.IsNullOrWhiteSpace(u.RealName) ? u.RealName : u.Account);
  264. foreach (var row in list)
  265. {
  266. row.ResponsibleDeptName = ResolveDeptName(row.ResponsibleDeptId, deptMap);
  267. row.OccurrenceDeptName = ResolveDeptName(row.OccurrenceDeptId, deptMap);
  268. row.AssigneeName = row.AssigneeId.HasValue ? empMap.GetValueOrDefault(row.AssigneeId.Value) : null;
  269. if (row is AdoS8ExceptionDetailDto detail)
  270. {
  271. // S8-REPORTER-IDSPACE-FIX-1:empMap(EmployeeMaster.RecID)优先,reporterUserMap(SysUser.Id)兜底兼容旧数据。
  272. detail.ReporterName = detail.ReporterId.HasValue
  273. ? (empMap.GetValueOrDefault(detail.ReporterId.Value) ?? reporterUserMap.GetValueOrDefault(detail.ReporterId.Value))
  274. : null;
  275. detail.VerifierName = detail.VerifierId.HasValue ? empMap.GetValueOrDefault(detail.VerifierId.Value) : null;
  276. }
  277. }
  278. }
  279. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):dept fallback —— 0/缺失=未归属,查不到名=部门ID:{id}。
  280. private static string? ResolveDeptName(long deptId, Dictionary<long, string> deptMap)
  281. {
  282. if (deptId <= 0) return "未归属";
  283. return deptMap.TryGetValue(deptId, out var name) && !string.IsNullOrWhiteSpace(name)
  284. ? name
  285. : $"部门ID:{deptId}";
  286. }
  287. /// <summary>
  288. /// S2-EW:debug-only 软删 RelatedObjectCode 以 "TEST_G09_" 开头的异常。
  289. /// 调用方仅限 <see cref="Controllers.S8.AdoS8WatchDebugController"/>,由其 _debugEndpointEnabled 守门。
  290. /// 实现纪律:
  291. /// - 仅 SET IsDeleted=true,不动业务字段(status/closed_at/source_rule_id 等)。
  292. /// - 前缀字面量硬编码,不接受外部 prefix;防止前缀注入。
  293. /// - 不联动 timeline / decision / evidence;业务读路径以父 IsDeleted 屏蔽。
  294. /// </summary>
  295. public async Task<int> SoftDeleteTestPrefixAsync(long tenantId, long factoryId)
  296. {
  297. const string prefix = "TEST_G09_";
  298. var affected = await _rep.AsUpdateable()
  299. .SetColumns(e => new AdoS8Exception { IsDeleted = true })
  300. .Where(e => e.TenantId == tenantId
  301. && e.FactoryId == factoryId
  302. && !e.IsDeleted
  303. && e.RelatedObjectCode != null
  304. && e.RelatedObjectCode.StartsWith(prefix))
  305. .ExecuteCommandAsync();
  306. System.Diagnostics.Trace.TraceWarning(
  307. $"[S2-EW] soft-deleted {affected} TEST_G09_ exceptions, tenant={tenantId} factory={factoryId}");
  308. return affected;
  309. }
  310. }