S8ExceptionService.cs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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. using Microsoft.Extensions.Options;
  8. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  9. public class S8ExceptionService : ITransient
  10. {
  11. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  12. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  13. private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
  14. private readonly SqlSugarRepository<SysUser> _sysUserRep;
  15. private readonly S8ImpactMetricsService _impactMetricsService;
  16. // S8-EXCEPTION-DEPT-NAME-HYDRATION-MASTERDATA-FACTORY-FIX-1:部门名水合按 DepartmentMaster 主数据 factory 解析。
  17. private readonly S8MasterDataOptions _masterDataOptions;
  18. // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。
  19. // 超过此阈值时降级为 CreatedAt DESC 走 DB 分页,避免全量取 → 内存排序在生产规模下退化。
  20. private const int ImpactSortCandidateCap = 2000;
  21. // S8-EXCEPTION-DEPT-NAME-HYDRATION-MASTERDATA-FACTORY-FIX-1:未归属部门 codename(与 S8ManualReportService 同协议常量值)。
  22. private const string UnassignedDepartmentCode = "D-UNASSIGNED";
  23. public S8ExceptionService(
  24. SqlSugarRepository<AdoS8Exception> rep,
  25. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  26. SqlSugarRepository<AdoS0EmployeeMaster> empRep,
  27. SqlSugarRepository<SysUser> sysUserRep,
  28. S8ImpactMetricsService impactMetricsService,
  29. IOptions<S8MasterDataOptions> masterDataOptions)
  30. {
  31. _rep = rep;
  32. _deptRep = deptRep;
  33. _empRep = empRep;
  34. _sysUserRep = sysUserRep;
  35. _impactMetricsService = impactMetricsService;
  36. _masterDataOptions = masterDataOptions.Value;
  37. }
  38. public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
  39. {
  40. (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize);
  41. var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" };
  42. var includeUnclassified = q.IncludeUnclassified == true;
  43. // S8-SLA-TIMEOUT-RUNTIME-1(P3):方法入口取一次 now,供 q.TimeoutFlag 筛选 + 投影写 TimeoutFlag 共用,避免分页前后不一致。
  44. var timeoutNow = DateTime.Now;
  45. var query = _rep.Context.Queryable<AdoS8Exception>()
  46. .LeftJoin<AdoS8SceneConfig>((e, sc) =>
  47. e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
  48. .LeftJoin<AdoS8WatchRule>((e, sc, wr) =>
  49. e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode)
  50. .Where((e, sc, wr) => e.TenantId == q.TenantId && e.FactoryId == q.FactoryId && !e.IsDeleted)
  51. // S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-1):默认列表的「已分类」口径同时要求 stage_code 非空。
  52. // stage_code 是 Monitoring 聚合键(GetSummaryAsync 按 S8ModuleCode.All 过滤),为空即「未归入任何业务域」,
  53. // 与 exception_type_code 为空同属未分类。所有建单入口(S8ManualReportService 的手工/watch/hit 三分支)
  54. // 都会写 stage_code,故本条只影响历史遗留行;下钻仍可用 includeUnclassified=true 查看全量。
  55. .WhereIF(!includeUnclassified, (e, sc, wr) =>
  56. !SqlFunc.IsNullOrEmpty(e.ExceptionTypeCode) && !SqlFunc.IsNullOrEmpty(e.StageCode))
  57. .WhereIF(!string.IsNullOrWhiteSpace(q.Status), (e, sc, wr) => e.Status == q.Status)
  58. .WhereIF(string.IsNullOrWhiteSpace(q.Status) && q.StatusBucket == "pending",
  59. (e, sc, wr) => pendingStatuses.Contains(e.Status))
  60. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:legacy 兼容 — q.Severity 传 LOW/MEDIUM/HIGH/CRITICAL 时
  61. // 通过 Normalize 映射为 FOLLOW/SERIOUS 再过滤。
  62. .WhereIF(!string.IsNullOrWhiteSpace(q.Severity),
  63. (e, sc, wr) => e.Severity == S8SeverityCode.Normalize(q.Severity))
  64. .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), (e, sc, wr) => e.SceneCode == q.SceneCode)
  65. .WhereIF(!string.IsNullOrWhiteSpace(q.ModuleCode), (e, sc, wr) => e.ModuleCode == q.ModuleCode)
  66. // S8-DASHBOARD-DATA-ALIGN-S1S7-1:看板明细表传 OnlyS1S7Modules=true 时与 KPI 口径对齐;
  67. // 异常列表页不传该参数,行为保持不变(仍可见 NULL module 的 legacy 行)。
  68. .WhereIF(q.OnlyS1S7Modules == true, (e, sc, wr) => S8ModuleCode.All.Contains(e.ModuleCode))
  69. .WhereIF(q.DeptId.HasValue, (e, sc, wr) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value)
  70. // S8-SLA-TIMEOUT-RUNTIME-1(P3):q.TimeoutFlag 由 timeout_flag 字段筛选切到 sla_deadline + status 在线计算。
  71. // q.TimeoutFlag=true → 当前超时;q.TimeoutFlag=false → 未当前超时(含未配 SLA / 已关闭 / 已恢复)。
  72. .WhereIF(q.TimeoutFlag == true, (e, sc, wr) => e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED")
  73. .WhereIF(q.TimeoutFlag == false, (e, sc, wr) => !(e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED"))
  74. .WhereIF(q.BeginTime.HasValue, (e, sc, wr) => e.CreatedAt >= q.BeginTime!.Value)
  75. .WhereIF(q.EndTime.HasValue, (e, sc, wr) => e.CreatedAt <= q.EndTime!.Value)
  76. .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc, wr) => e.ProcessNodeCode == q.ProcessNodeCode)
  77. .WhereIF(!string.IsNullOrWhiteSpace(q.RelatedObjectCode), (e, sc, wr) => e.RelatedObjectCode == q.RelatedObjectCode)
  78. .WhereIF(!string.IsNullOrWhiteSpace(q.OrderFlowCode), (e, sc, wr) => e.OrderFlowCode == q.OrderFlowCode)
  79. .WhereIF(!string.IsNullOrWhiteSpace(q.StageCode), (e, sc, wr) => e.StageCode == q.StageCode)
  80. .WhereIF(!string.IsNullOrWhiteSpace(q.RuleMechanism), (e, sc, wr) => e.RuleMechanism == q.RuleMechanism)
  81. .WhereIF(q.RecoveredStatus == "RECOVERED", (e, sc, wr) => e.RecoveredAt != null)
  82. .WhereIF(q.RecoveredStatus == "ACTIVE", (e, sc, wr) => e.RecoveredAt == null)
  83. .WhereIF(!string.IsNullOrWhiteSpace(q.RuleType), (e, sc, wr) => wr.RuleType == q.RuleType)
  84. .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword),
  85. (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
  86. var total = await query.CountAsync();
  87. // S8-DEMO-IMPACT-SORT-NOTICE-1:候选集合 <= ImpactSortCandidateCap 时走"全量取 → 内存填影响 → 排序 → 分页",
  88. // 支持 impactScore / repeatCount30d / cumulativeLossHours30d 等运行期字段排序;超过则降级 DB 分页 createdAt DESC。
  89. var useImpactSort = total > 0 && total <= ImpactSortCandidateCap;
  90. List<AdoS8ExceptionListItemDto> list;
  91. if (useImpactSort)
  92. {
  93. // 先按 query 命中集合一次性取候选,order 仅 createdAt DESC 用于稳定性,后续内存覆写排序。
  94. var candidates = await query
  95. .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
  96. .Take(ImpactSortCandidateCap)
  97. .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
  98. {
  99. Id = e.Id,
  100. FactoryId = e.FactoryId,
  101. ExceptionCode = e.ExceptionCode,
  102. Title = e.Title,
  103. Status = e.Status,
  104. Severity = e.Severity,
  105. PriorityScore = e.PriorityScore,
  106. PriorityLevel = e.PriorityLevel,
  107. SceneCode = e.SceneCode,
  108. SceneName = sc.SceneName,
  109. ModuleCode = e.ModuleCode,
  110. ResponsibleDeptId = e.ResponsibleDeptId,
  111. OccurrenceDeptId = e.OccurrenceDeptId,
  112. AssigneeId = e.AssigneeId,
  113. SlaDeadline = e.SlaDeadline,
  114. TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
  115. CreatedAt = e.CreatedAt,
  116. ClosedAt = e.ClosedAt,
  117. ExceptionTypeCode = e.ExceptionTypeCode,
  118. RecoveredAt = e.RecoveredAt,
  119. SourceRuleCode = e.SourceRuleCode,
  120. SourceObjectType = e.SourceObjectType,
  121. SourceObjectId = e.SourceObjectId,
  122. DedupKey = e.DedupKey,
  123. LastDetectedAt = e.LastDetectedAt,
  124. RuleType = wr.RuleType,
  125. RelatedObjectCode = e.RelatedObjectCode,
  126. OrderFlowCode = e.OrderFlowCode,
  127. StageCode = e.StageCode,
  128. RuleMechanism = e.RuleMechanism,
  129. })
  130. .ToListAsync();
  131. // DTO 后处理:损失时间 / 是否超时关闭 / 标签。
  132. foreach (var r in candidates)
  133. {
  134. r.StatusLabel = S8Labels.StatusLabel(r.Status);
  135. r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
  136. r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
  137. var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
  138. r.LossHours = lossHours;
  139. r.IsOverdueClosed = isOverdueClosed;
  140. }
  141. // 影响统计批量填充(30 天窗口聚合一次性 GROUP BY,O(n) 回填,无 N+1)。
  142. var impactRows = candidates
  143. .Select(r => new ExceptionImpactRow
  144. {
  145. Id = r.Id,
  146. ExceptionTypeCode = r.ExceptionTypeCode,
  147. Severity = r.Severity,
  148. CreatedAt = r.CreatedAt,
  149. ClosedAt = r.ClosedAt,
  150. TimeoutFlag = r.TimeoutFlag,
  151. })
  152. .ToList();
  153. await _impactMetricsService.FillBatchAsync(q.TenantId, q.FactoryId, impactRows);
  154. var impactById = impactRows.ToDictionary(x => x.Id);
  155. foreach (var r in candidates)
  156. {
  157. if (!impactById.TryGetValue(r.Id, out var snap)) continue;
  158. r.RepeatCount30d = snap.RepeatCount30d;
  159. r.CumulativeLossHours30d = snap.CumulativeLossHours30d;
  160. r.ImpactScore = snap.ImpactScore;
  161. r.SuggestedAttentionLevel = snap.SuggestedAttentionLevel;
  162. r.SuggestedAttentionLabel = snap.SuggestedAttentionLabel;
  163. r.ImpactReason = snap.ImpactReason;
  164. }
  165. // 内存排序(白名单,非白名单回退 createdAt DESC)。
  166. var sorted = ApplyImpactSort(candidates, q.SortField, q.SortOrder);
  167. // 内存分页。
  168. list = sorted
  169. .Skip((q.Page - 1) * q.PageSize)
  170. .Take(q.PageSize)
  171. .ToList();
  172. }
  173. else
  174. {
  175. // 降级路径:候选 > ImpactSortCandidateCap,DB 分页 createdAt DESC,不填充影响字段(前端展示 0/null)。
  176. list = await query
  177. .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
  178. .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
  179. {
  180. Id = e.Id,
  181. FactoryId = e.FactoryId,
  182. ExceptionCode = e.ExceptionCode,
  183. Title = e.Title,
  184. Status = e.Status,
  185. Severity = e.Severity,
  186. PriorityScore = e.PriorityScore,
  187. PriorityLevel = e.PriorityLevel,
  188. SceneCode = e.SceneCode,
  189. SceneName = sc.SceneName,
  190. ModuleCode = e.ModuleCode,
  191. ResponsibleDeptId = e.ResponsibleDeptId,
  192. OccurrenceDeptId = e.OccurrenceDeptId,
  193. AssigneeId = e.AssigneeId,
  194. SlaDeadline = e.SlaDeadline,
  195. TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
  196. CreatedAt = e.CreatedAt,
  197. ClosedAt = e.ClosedAt,
  198. ExceptionTypeCode = e.ExceptionTypeCode,
  199. RecoveredAt = e.RecoveredAt,
  200. SourceRuleCode = e.SourceRuleCode,
  201. SourceObjectType = e.SourceObjectType,
  202. SourceObjectId = e.SourceObjectId,
  203. DedupKey = e.DedupKey,
  204. LastDetectedAt = e.LastDetectedAt,
  205. RuleType = wr.RuleType,
  206. RelatedObjectCode = e.RelatedObjectCode,
  207. OrderFlowCode = e.OrderFlowCode,
  208. StageCode = e.StageCode,
  209. RuleMechanism = e.RuleMechanism,
  210. })
  211. .ToPageListAsync(q.Page, q.PageSize);
  212. foreach (var r in list)
  213. {
  214. r.StatusLabel = S8Labels.StatusLabel(r.Status);
  215. r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
  216. r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
  217. var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
  218. r.LossHours = lossHours;
  219. r.IsOverdueClosed = isOverdueClosed;
  220. }
  221. }
  222. await FillDisplayNamesAsync(list, q.FactoryId);
  223. return (total, list);
  224. }
  225. // S8-DEMO-IMPACT-SORT-NOTICE-1:白名单排序;severity 自定义键(SERIOUS=2 / FOLLOW=1 / 其他=0)。
  226. // sortField 空或非白名单 → createdAt DESC;sortOrder 非 asc/desc → desc。
  227. private static List<AdoS8ExceptionListItemDto> ApplyImpactSort(
  228. List<AdoS8ExceptionListItemDto> rows, string? sortField, string? sortOrder)
  229. {
  230. var asc = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase);
  231. var key = (sortField ?? string.Empty).Trim();
  232. return key switch
  233. {
  234. "impactScore" => asc
  235. ? rows.OrderBy(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList()
  236. : rows.OrderByDescending(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList(),
  237. "repeatCount30d" => asc
  238. ? rows.OrderBy(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList()
  239. : rows.OrderByDescending(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList(),
  240. "cumulativeLossHours30d" => asc
  241. ? rows.OrderBy(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList()
  242. : rows.OrderByDescending(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList(),
  243. "severity" => asc
  244. ? rows.OrderBy(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList()
  245. : rows.OrderByDescending(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList(),
  246. "priorityScore" => asc
  247. ? rows.OrderBy(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList()
  248. : rows.OrderByDescending(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList(),
  249. "createdAt" => asc
  250. ? rows.OrderBy(r => r.CreatedAt).ToList()
  251. : rows.OrderByDescending(r => r.CreatedAt).ToList(),
  252. _ => rows.OrderByDescending(r => r.CreatedAt).ToList(),
  253. };
  254. }
  255. private static int SeveritySortKey(string severity) => severity switch
  256. {
  257. "SERIOUS" => 2,
  258. "FOLLOW" => 1,
  259. _ => 0,
  260. };
  261. public async Task<object> GetFilterOptionsAsync(long tenantId, long factoryId)
  262. {
  263. var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
  264. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
  265. .OrderBy(x => x.SortNo)
  266. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  267. .ToListAsync();
  268. var departments = await _deptRep.AsQueryable()
  269. .Where(x => x.FactoryRefId == factoryId)
  270. .OrderBy(x => x.Department)
  271. .Take(500)
  272. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  273. .ToListAsync();
  274. // S8-EXCEPTION-MODULE-DISPLAY-1:业务展示主口径切到 module_code(S1-S7)。
  275. // scenes 保留兼容期,前端筛选已切到 modules。
  276. var modules = S8ModuleCode.All
  277. .Select(code => new { value = code, label = S8ModuleCode.Label(code) })
  278. .ToList();
  279. return new
  280. {
  281. statuses = S8Labels.StatusOptions(),
  282. severities = S8Labels.SeverityOptions(),
  283. scenes,
  284. modules,
  285. departments
  286. };
  287. }
  288. public async Task<AdoS8ExceptionDetailDto?> GetDetailAsync(long id, long tenantId, long factoryId)
  289. {
  290. // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。
  291. var timeoutNow = DateTime.Now;
  292. var rows = await _rep.Context.Queryable<AdoS8Exception>()
  293. .LeftJoin<AdoS8SceneConfig>((e, sc) =>
  294. e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
  295. .LeftJoin<AdoS8WatchRule>((e, sc, wr) =>
  296. e.TenantId == wr.TenantId && e.FactoryId == wr.FactoryId && e.SourceRuleCode == wr.RuleCode)
  297. .Where((e, sc, wr) => e.Id == id && e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  298. .Select((e, sc, wr) => new AdoS8ExceptionDetailDto
  299. {
  300. Id = e.Id,
  301. FactoryId = e.FactoryId,
  302. ExceptionCode = e.ExceptionCode,
  303. Title = e.Title,
  304. Description = e.Description,
  305. Status = e.Status,
  306. Severity = e.Severity,
  307. PriorityScore = e.PriorityScore,
  308. PriorityLevel = e.PriorityLevel,
  309. SceneCode = e.SceneCode,
  310. SceneName = sc.SceneName,
  311. ModuleCode = e.ModuleCode,
  312. SourceType = e.SourceType,
  313. OccurrenceDeptId = e.OccurrenceDeptId,
  314. ResponsibleDeptId = e.ResponsibleDeptId,
  315. ResponsibleGroupId = e.ResponsibleGroupId,
  316. AssigneeId = e.AssigneeId,
  317. ReporterId = e.ReporterId,
  318. SlaDeadline = e.SlaDeadline,
  319. // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。
  320. TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
  321. CreatedAt = e.CreatedAt,
  322. ClosedAt = e.ClosedAt,
  323. AssignedAt = e.AssignedAt,
  324. UpdatedAt = e.UpdatedAt,
  325. ActiveFlowInstanceId = e.ActiveFlowInstanceId,
  326. ActiveFlowBizType = e.ActiveFlowBizType,
  327. VerifierId = e.VerifierId,
  328. VerificationAssignedAt = e.VerificationAssignedAt,
  329. VerifiedAt = e.VerifiedAt,
  330. VerificationResult = e.VerificationResult,
  331. VerificationRemark = e.VerificationRemark,
  332. SourceRuleId = e.SourceRuleId,
  333. RelatedObjectCode = e.RelatedObjectCode,
  334. DedupKey = e.DedupKey,
  335. LastDetectedAt = e.LastDetectedAt,
  336. RecoveredAt = e.RecoveredAt,
  337. SourceRuleCode = e.SourceRuleCode,
  338. SourceObjectType = e.SourceObjectType,
  339. SourceObjectId = e.SourceObjectId,
  340. ExceptionTypeCode = e.ExceptionTypeCode,
  341. RuleType = wr.RuleType,
  342. OrderFlowCode = e.OrderFlowCode,
  343. StageCode = e.StageCode,
  344. RuleMechanism = e.RuleMechanism,
  345. })
  346. .Take(1)
  347. .ToListAsync();
  348. if (rows.Count == 0) return null;
  349. var d = rows[0];
  350. d.StatusLabel = S8Labels.StatusLabel(d.Status);
  351. d.SeverityLabel = S8Labels.SeverityLabel(d.Severity);
  352. d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!);
  353. // S8-DEMO-CORE-FIELD-COMPLETE-1:详情与列表同口径,DTO 后处理计算损失时间 / 是否超时关闭。
  354. var (lossHours, isOverdueClosed) = ComputeClosureMetrics(d.CreatedAt, d.ClosedAt, d.SlaDeadline);
  355. d.LossHours = lossHours;
  356. d.IsOverdueClosed = isOverdueClosed;
  357. await FillDisplayNamesAsync(new[] { d }, factoryId);
  358. return d;
  359. }
  360. // S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,1 位小数)+ 是否超时关闭(冻结判定)。
  361. // 未关闭 → LossHours=null;未关闭或无 SLA → IsOverdueClosed=null。与运行时 TimeoutFlag 不复用。
  362. private static (decimal? lossHours, bool? isOverdueClosed) ComputeClosureMetrics(
  363. DateTime createdAt, DateTime? closedAt, DateTime? slaDeadline)
  364. {
  365. decimal? lossHours = closedAt == null
  366. ? (decimal?)null
  367. : Math.Round((decimal)(closedAt.Value - createdAt).TotalHours, 1);
  368. bool? isOverdueClosed = (closedAt == null || slaDeadline == null)
  369. ? (bool?)null
  370. : closedAt.Value > slaDeadline.Value;
  371. return (lossHours, isOverdueClosed);
  372. }
  373. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName;
  374. // 部门查询加 factory_ref_id 二次约束,避免跨 factory 同名 / 同 RecID 错位。
  375. private async Task FillDisplayNamesAsync(IEnumerable<AdoS8ExceptionListItemDto> rows, long factoryId)
  376. {
  377. var list = rows.ToList();
  378. if (list.Count == 0) return;
  379. var deptIds = list
  380. .Select(x => x.ResponsibleDeptId)
  381. .Concat(list.Select(x => x.OccurrenceDeptId))
  382. .Where(x => x > 0)
  383. .Distinct()
  384. .ToList();
  385. var empIds = list
  386. .Select(x => x.AssigneeId ?? 0L)
  387. .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.ReporterId ?? 0L))
  388. .Concat(list.OfType<AdoS8ExceptionDetailDto>().Select(x => x.VerifierId ?? 0L))
  389. .Where(x => x > 0)
  390. .Distinct()
  391. .ToList();
  392. // S8-DEPARTMENT-LOOKUP-CLOSURE-1(B-1):部门水合改用**当前请求的 trusted factoryId**,
  393. // 与 S8DashboardService 既有可工作范式(d.FactoryRefId == factoryId)对齐。
  394. //
  395. // 原实现经 S8MasterData.DepartmentFactoryRefId 解析主数据 factory;该配置是**全局单值**,
  396. // 在多租户下必然只能服务一个租户:实测它指向 factory 1329900200002,其下 DepartmentMaster
  397. // 仅 1 行且 tenant_id=797403760988229,导致
  398. // * 其余租户(含 UAT 838257186181189)部门 0/N 解析,全部回落未解析文案;
  399. // * 唯一"解析成功"的 7 条实为**跨租户读取**(异常属 797403760988230,部门属 797403760988229)。
  400. // 各 S8 运营租户的 DepartmentMaster.factory_ref_id 均等于其运营 factoryId(实测每租户 factory 唯一),
  401. // 故 trusted factoryId 即正确的主数据边界,且天然不跨租户。
  402. //
  403. // 配置项本身保留(S8ManualReportService 的自动建单写路径仍在用),但对本显示路径而言标记为
  404. // LEGACY / NON-MULTITENANT-SAFE FOR S8 EXCEPTION LOOKUP,不再让它无条件覆盖 trusted runtime factory。
  405. var deptMap = deptIds.Count == 0
  406. ? new Dictionary<long, string>()
  407. : (await _deptRep.AsQueryable().ClearFilter()
  408. .Where(x => deptIds.Contains(x.Id) && x.FactoryRefId == factoryId)
  409. .Select(x => new { x.Id, Name = x.Descr ?? x.Department })
  410. .ToListAsync())
  411. .ToDictionary(x => x.Id, x => x.Name);
  412. // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位(797403760988229 ≠ 系统租户),
  413. // 走全局 multi-tenant filter 会全过滤为空 → 详情页处理人/检验人姓名显示数字 ID。
  414. // 与 S8MasterDataAdapter.GetEmployeesAsync / S8TaskFlowService.GetEmployeeSysUserIdAsync 同口径,
  415. // 局部 ClearFilter + employee.Id 主键集合作为安全边界,无跨租户泄漏。
  416. var empMap = empIds.Count == 0
  417. ? new Dictionary<long, string>()
  418. : (await _empRep.AsQueryable().ClearFilter()
  419. .Where(x => empIds.Contains(x.Id))
  420. .Select(x => new { x.Id, Name = x.Name ?? x.Employee })
  421. .ToListAsync())
  422. .ToDictionary(x => x.Id, x => x.Name);
  423. // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):ReporterId 新协议 = EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
  424. // 水合优先级:EmployeeMaster.Name → fallback SysUser.RealName/Account(兼容旧数据 reporter=SysUser.Id)。
  425. // 旧注释 “ReporterId 现在记录 sysUser.Id” 已失效,由本批协议替代。
  426. var reporterUserIds = list.OfType<AdoS8ExceptionDetailDto>()
  427. .Select(x => x.ReporterId ?? 0L)
  428. .Where(x => x > 0)
  429. .Distinct()
  430. .ToList();
  431. var reporterUserMap = reporterUserIds.Count == 0
  432. ? new Dictionary<long, string>()
  433. : (await _sysUserRep.AsQueryable().ClearFilter()
  434. .Where(u => reporterUserIds.Contains(u.Id))
  435. .Select(u => new { u.Id, u.RealName, u.Account })
  436. .ToListAsync())
  437. .ToDictionary(
  438. u => u.Id,
  439. u => !string.IsNullOrWhiteSpace(u.RealName) ? u.RealName : u.Account);
  440. foreach (var row in list)
  441. {
  442. row.ResponsibleDeptName = ResolveDeptName(row.ResponsibleDeptId, deptMap);
  443. row.OccurrenceDeptName = ResolveDeptName(row.OccurrenceDeptId, deptMap);
  444. row.AssigneeName = row.AssigneeId.HasValue ? empMap.GetValueOrDefault(row.AssigneeId.Value) : null;
  445. if (row is AdoS8ExceptionDetailDto detail)
  446. {
  447. // S8-REPORTER-IDSPACE-FIX-1:empMap(EmployeeMaster.RecID)优先,reporterUserMap(SysUser.Id)兜底兼容旧数据。
  448. detail.ReporterName = detail.ReporterId.HasValue
  449. ? (empMap.GetValueOrDefault(detail.ReporterId.Value) ?? reporterUserMap.GetValueOrDefault(detail.ReporterId.Value))
  450. : null;
  451. detail.VerifierName = detail.VerifierId.HasValue ? empMap.GetValueOrDefault(detail.VerifierId.Value) : null;
  452. }
  453. }
  454. }
  455. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):dept fallback —— 0/缺失=未归属。
  456. // S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-2):解析不到主数据时不再回落「部门ID:{id}」——
  457. // 裸内部 ID 对业务用户无意义,且只可能出现在 FK 悬空的历史行上,统一显示为业务可读文案。
  458. internal const string UnresolvedLookupLabel = "历史数据 / 未配置";
  459. private static string? ResolveDeptName(long deptId, Dictionary<long, string> deptMap)
  460. {
  461. if (deptId <= 0) return "未归属";
  462. return deptMap.TryGetValue(deptId, out var name) && !string.IsNullOrWhiteSpace(name)
  463. ? name
  464. : UnresolvedLookupLabel;
  465. }
  466. // S8-DEPARTMENT-LOOKUP-CLOSURE-1(B-1):原 ResolveMasterDataDepartmentFactoryRefIdAsync 已移除。
  467. // 它把全局单值配置 S8MasterData.DepartmentFactoryRefId 作为部门主数据 factory,在多租户下不安全
  468. // (实测导致跨租户读取 + 其余租户全部解析失败)。显示路径改为直接使用 trusted factoryId,
  469. // 与 S8DashboardService 同口径。配置项与 S8ManualReportService 的写路径用法保持不变,未触碰。
  470. /// <summary>
  471. /// S2-EW:debug-only 软删 RelatedObjectCode 以 "TEST_G09_" 开头的异常。
  472. /// 调用方仅限 <see cref="Controllers.S8.AdoS8WatchDebugController"/>,由其 _debugEndpointEnabled 守门。
  473. /// 实现纪律:
  474. /// - 仅 SET IsDeleted=true,不动业务字段(status/closed_at/source_rule_id 等)。
  475. /// - 前缀字面量硬编码,不接受外部 prefix;防止前缀注入。
  476. /// - 不联动 timeline / decision / evidence;业务读路径以父 IsDeleted 屏蔽。
  477. /// </summary>
  478. public async Task<int> SoftDeleteTestPrefixAsync(long tenantId, long factoryId)
  479. {
  480. const string prefix = "TEST_G09_";
  481. var affected = await _rep.AsUpdateable()
  482. .SetColumns(e => new AdoS8Exception { IsDeleted = true })
  483. .Where(e => e.TenantId == tenantId
  484. && e.FactoryId == factoryId
  485. && !e.IsDeleted
  486. && e.RelatedObjectCode != null
  487. && e.RelatedObjectCode.StartsWith(prefix))
  488. .ExecuteCommandAsync();
  489. System.Diagnostics.Trace.TraceWarning(
  490. $"[S2-EW] soft-deleted {affected} TEST_G09_ exceptions, tenant={tenantId} factory={factoryId}");
  491. return affected;
  492. }
  493. }