S8DashboardCellDataService.cs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  5. /// <summary>
  6. /// 大屏卡片数据查询服务:按 (pageCode, cellCode) 读取配置,按 binding_type 分发组装结果。
  7. /// 配置解析:工厂覆盖优先(tenant+factory 精确匹配)→ 全局基线(tenant=0/factory=0)兜底。
  8. /// </summary>
  9. public class S8DashboardCellDataService : ITransient
  10. {
  11. private readonly SqlSugarRepository<AdoS8DashboardCellConfig> _cfgRep;
  12. private readonly SqlSugarRepository<AdoS8Exception> _exRep;
  13. private readonly SqlSugarRepository<SysOrg> _orgRep;
  14. public S8DashboardCellDataService(
  15. SqlSugarRepository<AdoS8DashboardCellConfig> cfgRep,
  16. SqlSugarRepository<AdoS8Exception> exRep,
  17. SqlSugarRepository<SysOrg> orgRep)
  18. {
  19. _cfgRep = cfgRep;
  20. _exRep = exRep;
  21. _orgRep = orgRep;
  22. }
  23. public async Task<AdoS8CellDataDto> GetAsync(AdoS8CellDataQueryDto q)
  24. {
  25. if (string.IsNullOrWhiteSpace(q.PageCode) || string.IsNullOrWhiteSpace(q.CellCode))
  26. throw new S8BizException("pageCode 和 cellCode 必填");
  27. var cfg = await ResolveConfigAsync(q.TenantId, q.PageCode, q.CellCode);
  28. if (cfg is null)
  29. return new AdoS8CellDataDto
  30. {
  31. PageCode = q.PageCode,
  32. CellCode = q.CellCode,
  33. Message = "未配置",
  34. };
  35. if (!cfg.Enabled)
  36. return new AdoS8CellDataDto
  37. {
  38. PageCode = q.PageCode,
  39. CellCode = q.CellCode,
  40. BindingType = cfg.BindingType,
  41. Title = cfg.CellTitle,
  42. StatMetric = cfg.StatMetric,
  43. TimeWindow = cfg.TimeWindow,
  44. Message = "已禁用",
  45. };
  46. var (from, to) = TimeRange(cfg.TimeWindow);
  47. var effectiveDeptGroupBy = !string.IsNullOrWhiteSpace(q.DeptGroupBy) ? q.DeptGroupBy! : cfg.DeptGroupBy;
  48. // 取事实数据
  49. // S8-DASHBOARD-DATA-ALIGN-S1S7-1:统一只统计 module_code IN S1-S7;不依赖 SceneCode 派生模块。
  50. var events = await _exRep.AsQueryable()
  51. // S8-TENANT-ONLY-BATCH6:事实数据只按租户取;本批之后异常 factory_id 恒为 0,
  52. // 保留 factory 等值会让看板卡片看不到任何新异常。
  53. .Where(e => e.TenantId == q.TenantId && !e.IsDeleted)
  54. .Where(e => S8ModuleCode.All.Contains(e.ModuleCode))
  55. .Where(e => e.CreatedAt >= from && e.CreatedAt < to)
  56. .Select(e => new EvtRow
  57. {
  58. Status = e.Status,
  59. ExceptionTypeCode = e.ExceptionTypeCode,
  60. ModuleCode = e.ModuleCode,
  61. SceneCode = e.SceneCode,
  62. ResponsibleDeptId = e.ResponsibleDeptId,
  63. OccurrenceDeptId = e.OccurrenceDeptId,
  64. CreatedAt = e.CreatedAt,
  65. ClosedAt = e.ClosedAt,
  66. })
  67. .ToListAsync();
  68. // 按 binding_type 过滤
  69. IEnumerable<EvtRow> scoped = cfg.BindingType switch
  70. {
  71. "EXCEPTION_TYPE" => events.Where(e => !string.IsNullOrEmpty(cfg.ExceptionTypeCode) && e.ExceptionTypeCode == cfg.ExceptionTypeCode),
  72. "AGGREGATE" => ApplyAggregateScope(events, cfg.AggregateScope),
  73. _ => events, // CUSTOM:不按类型过滤,返回同页范围聚合值供前端参考
  74. };
  75. var list = scoped.ToList();
  76. var dto = new AdoS8CellDataDto
  77. {
  78. CellCode = cfg.CellCode,
  79. PageCode = cfg.PageCode,
  80. Title = cfg.CellTitle,
  81. BindingType = cfg.BindingType,
  82. StatMetric = cfg.StatMetric,
  83. TimeWindow = cfg.TimeWindow,
  84. Value = ComputeValue(list, cfg.StatMetric),
  85. };
  86. // 明细分组:AGGREGATE 按部门;EXCEPTION_TYPE 可选按部门;CUSTOM 跳过
  87. if (cfg.BindingType == "AGGREGATE" || cfg.BindingType == "EXCEPTION_TYPE")
  88. {
  89. dto.Breakdown = await BuildDeptBreakdownAsync(list, effectiveDeptGroupBy, cfg.StatMetric);
  90. }
  91. return dto;
  92. }
  93. private async Task<AdoS8DashboardCellConfig?> ResolveConfigAsync(long tenantId, string pageCode, string cellCode)
  94. {
  95. var rows = await _cfgRep.AsQueryable()
  96. .Where(x => x.PageCode == pageCode && x.CellCode == cellCode
  97. && (x.TenantId == S8ConfigScope.GlobalTenantId || x.TenantId == tenantId))
  98. .ToListAsync();
  99. // precedence:租户行优先于平台默认。原判据是 FactoryId 降序,
  100. // 在租户行 factory_id 盖章为 0 之后会与平台默认打平,退化成任意序。
  101. return rows
  102. .OrderByDescending(x => x.TenantId != S8ConfigScope.GlobalTenantId)
  103. .FirstOrDefault();
  104. }
  105. private static (DateTime from, DateTime to) TimeRange(string window)
  106. {
  107. var now = DateTime.Now;
  108. return window switch
  109. {
  110. "TODAY" => (now.Date, now.Date.AddDays(1)),
  111. "LAST_24H" => (now.AddHours(-24), now.AddMinutes(1)),
  112. "LAST_7D" => (now.AddDays(-7), now.AddMinutes(1)),
  113. "LAST_30D" => (now.AddDays(-30), now.AddMinutes(1)),
  114. _ => (now.AddHours(-24), now.AddMinutes(1)),
  115. };
  116. }
  117. // S8-DASHBOARD-DATA-ALIGN-S1S7-1:DOMAIN_x 统一按 ModuleCode 归类(不再依赖 SceneCode 与 legacy 复合场景)。
  118. // 上游 events 已过滤 module_code IN S1-S7,此处仅按业务域切片。
  119. private static IEnumerable<EvtRow> ApplyAggregateScope(List<EvtRow> events, string? scope) => scope switch
  120. {
  121. "DOMAIN_DELIVERY" => events.Where(e => e.ModuleCode == "S1" || e.ModuleCode == "S7"),
  122. "DOMAIN_PRODUCTION" => events.Where(e => e.ModuleCode == "S2" || e.ModuleCode == "S6"),
  123. "DOMAIN_SUPPLY" => events.Where(e => e.ModuleCode == "S3" || e.ModuleCode == "S4" || e.ModuleCode == "S5"),
  124. "ALL" => events,
  125. _ => events,
  126. };
  127. private static double ComputeValue(List<EvtRow> rows, string metric)
  128. {
  129. if (rows.Count == 0) return 0;
  130. return metric switch
  131. {
  132. "OPEN_COUNT" => rows.Count(e => e.Status != "CLOSED"),
  133. "FREQUENCY" => rows.Count,
  134. "AVG_DURATION" => AvgHours(rows),
  135. "CLOSE_RATE" => Math.Round(rows.Count(e => e.Status == "CLOSED") * 100.0 / rows.Count, 1),
  136. _ => rows.Count,
  137. };
  138. }
  139. private static double AvgHours(List<EvtRow> rows)
  140. {
  141. var closed = rows.Where(r => r.ClosedAt.HasValue).ToList();
  142. if (closed.Count == 0) return 0;
  143. return Math.Round(closed.Average(r => (r.ClosedAt!.Value - r.CreatedAt).TotalHours), 1);
  144. }
  145. private async Task<List<AdoS8CellBreakdownItem>> BuildDeptBreakdownAsync(List<EvtRow> rows, string groupBy, string metric)
  146. {
  147. Func<EvtRow, long> keySel = groupBy == "OCCUR"
  148. ? (EvtRow e) => e.OccurrenceDeptId
  149. : (EvtRow e) => e.ResponsibleDeptId;
  150. var groups = rows.Where(e => keySel(e) > 0).GroupBy(keySel).ToList();
  151. if (groups.Count == 0) return new();
  152. var deptIds = groups.Select(g => g.Key).Distinct().ToList();
  153. var names = (await _orgRep.AsQueryable().Where(o => deptIds.Contains(o.Id)).Select(o => new { o.Id, o.Name }).ToListAsync())
  154. .ToDictionary(o => o.Id, o => o.Name);
  155. return groups.Select(g =>
  156. {
  157. var list = g.ToList();
  158. return new AdoS8CellBreakdownItem
  159. {
  160. Code = g.Key.ToString(),
  161. Label = names.TryGetValue(g.Key, out var n) ? n : $"部门{g.Key}",
  162. Value = ComputeValue(list, metric),
  163. };
  164. })
  165. .OrderByDescending(i => i.Value)
  166. .ToList();
  167. }
  168. private class EvtRow
  169. {
  170. public string Status { get; set; } = string.Empty;
  171. public string? ExceptionTypeCode { get; set; }
  172. public string? ModuleCode { get; set; }
  173. public string? SceneCode { get; set; }
  174. public long ResponsibleDeptId { get; set; }
  175. public long OccurrenceDeptId { get; set; }
  176. public DateTime CreatedAt { get; set; }
  177. public DateTime? ClosedAt { get; set; }
  178. }
  179. }