AidopKanbanController.Diagnosis.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. namespace Admin.NET.Plugin.AiDOP.Controllers;
  4. public partial class AidopKanbanController
  5. {
  6. /// <summary>
  7. /// 智慧诊断:按模块和当前 L1 指标返回 L2/L3/L4 诊断矩阵。
  8. /// </summary>
  9. [HttpGet("smart-diagnosis/{moduleCode}")]
  10. public async Task<IActionResult> GetSmartDiagnosis(string moduleCode, [FromQuery] string? metricCode = null, [FromQuery] long factoryId = 1)
  11. {
  12. var mc = NormalizeDynamicDashboardModule(moduleCode);
  13. if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
  14. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  15. var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
  16. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  17. .OrderBy(x => x.MetricLevel)
  18. .OrderBy(x => x.SortNo)
  19. .ToListAsync();
  20. var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
  21. var l1Metrics = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).ToList();
  22. var root = ResolveDiagnosisRoot(l1Metrics, values, metricCode);
  23. if (root == null) return Ok(new { moduleCode = mc, metricCode, title = $"{mc} 智慧诊断", stages = Array.Empty<object>() });
  24. var children = kpis
  25. .Where(x => x.MetricLevel == 2 && x.ParentId == root.Id)
  26. .OrderBy(x => x.SortNo)
  27. .ToList();
  28. if (children.Count == 0)
  29. {
  30. children = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).Take(5).ToList();
  31. }
  32. var stagePayload = children.Select((kpi, index) =>
  33. {
  34. var current = BuildDiagnosisValue(kpi, values);
  35. var l3Rows = kpis
  36. .Where(x => x.MetricLevel == 3 && x.ParentId == kpi.Id)
  37. .OrderBy(x => x.SortNo)
  38. .ToList();
  39. var l4Rows = l3Rows.ToDictionary(
  40. x => x.MetricCode,
  41. x => kpis
  42. .Where(y => y.MetricLevel == 4 && y.ParentId == x.Id)
  43. .OrderBy(y => y.SortNo)
  44. .Select(y => BuildDiagnosisNode(y, values))
  45. .ToList(),
  46. StringComparer.OrdinalIgnoreCase);
  47. return new
  48. {
  49. key = kpi.MetricCode,
  50. name = kpi.MetricName,
  51. flowCaption = kpi.MetricName,
  52. dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department,
  53. kpi = FormatDiagnosisValue(current.Target, kpi.Unit),
  54. actualDays = FormatDiagnosisValue(current.Current, kpi.Unit),
  55. actualRate = BuildDiagnosisRate(kpi.MetricCode, current.Current, current.Target, kpi.Direction),
  56. status = current.Status,
  57. callout = BuildDiagnosisCallout(kpi.MetricName, current.Status, current.GapLabel),
  58. calloutTone = current.Status,
  59. sortNo = kpi.SortNo > 0 ? kpi.SortNo : index + 1,
  60. tier1 = l3Rows.Select(x => BuildDiagnosisNode(x, values)).ToList(),
  61. tier2 = l4Rows
  62. };
  63. }).ToList();
  64. var rootValue = BuildDiagnosisValue(root, values);
  65. var worstStage = stagePayload
  66. .OrderByDescending(x => DiagnosisSeverity(x.status))
  67. .ThenBy(x => x.sortNo)
  68. .FirstOrDefault();
  69. return Ok(new
  70. {
  71. moduleCode = mc,
  72. metricCode = root.MetricCode,
  73. metricName = root.MetricName,
  74. title = $"{mc} {ResolveDashboardModuleName(mc)} · 智慧诊断",
  75. subtitle = $"围绕 {root.MetricName} 自动定位下层问题指标",
  76. root = new
  77. {
  78. metricCode = root.MetricCode,
  79. metricName = root.MetricName,
  80. currentValue = rootValue.Current,
  81. targetValue = rootValue.Target,
  82. unit = root.Unit ?? "",
  83. status = rootValue.Status,
  84. gapLabel = rootValue.GapLabel
  85. },
  86. pmSummary = new
  87. {
  88. node = worstStage?.name ?? root.MetricName,
  89. leadDelta = rootValue.GapLabel ?? "0"
  90. },
  91. stages = stagePayload
  92. });
  93. }
  94. private static AdoSmartOpsKpiMaster? ResolveDiagnosisRoot(
  95. List<AdoSmartOpsKpiMaster> l1Metrics,
  96. Dictionary<string, DashboardMetricValueModel> values,
  97. string? metricCode)
  98. {
  99. if (!string.IsNullOrWhiteSpace(metricCode))
  100. {
  101. var specified = l1Metrics.FirstOrDefault(x => string.Equals(x.MetricCode, metricCode.Trim(), StringComparison.OrdinalIgnoreCase));
  102. if (specified != null) return specified;
  103. }
  104. return l1Metrics
  105. .OrderByDescending(x =>
  106. {
  107. values.TryGetValue(x.MetricCode, out var value);
  108. var status = (value?.StatusColor ?? "").ToLowerInvariant();
  109. return DiagnosisSeverity(status);
  110. })
  111. .ThenByDescending(x => DiagnosisDeviation(x, values))
  112. .ThenBy(x => x.SortNo)
  113. .FirstOrDefault();
  114. }
  115. private static object BuildDiagnosisNode(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  116. {
  117. var v = BuildDiagnosisValue(kpi, values);
  118. return new
  119. {
  120. key = kpi.MetricCode,
  121. name = kpi.MetricName,
  122. plan = FormatDiagnosisValue(v.Target, kpi.Unit),
  123. actual = FormatDiagnosisValue(v.Current, kpi.Unit),
  124. status = v.Status,
  125. dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department
  126. };
  127. }
  128. private static DiagnosisValue BuildDiagnosisValue(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  129. {
  130. values.TryGetValue(kpi.MetricCode, out var value);
  131. var status = AidopS4KpiMerge.AchievementLevel(
  132. value?.MetricValue,
  133. value?.TargetValue,
  134. kpi.Direction ?? "higher_is_better",
  135. kpi.YellowThreshold,
  136. kpi.RedThreshold);
  137. var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
  138. return new DiagnosisValue(value?.MetricValue, value?.TargetValue, status, FormatGapLabelGeneric(gap, kpi.Unit));
  139. }
  140. private static int DiagnosisSeverity(string? status) => (status ?? "").ToLowerInvariant() switch
  141. {
  142. "red" => 3,
  143. "yellow" => 2,
  144. "green" => 1,
  145. _ => 0
  146. };
  147. private static decimal DiagnosisDeviation(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  148. {
  149. if (!values.TryGetValue(kpi.MetricCode, out var value) || value.MetricValue == null || value.TargetValue == null || value.TargetValue == 0) return 0m;
  150. var current = value.MetricValue.Value;
  151. var target = value.TargetValue.Value;
  152. return string.Equals(kpi.Direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
  153. ? Math.Max(0m, (current - target) / Math.Abs(target))
  154. : Math.Max(0m, (target - current) / Math.Abs(target));
  155. }
  156. private static string FormatDiagnosisValue(decimal? value, string? unit)
  157. {
  158. if (value == null) return "-";
  159. var rounded = decimal.Round(value.Value, 2);
  160. return $"{rounded:0.##}{unit ?? ""}";
  161. }
  162. private static string BuildDiagnosisRate(string? metricCode, decimal? current, decimal? target, string? direction)
  163. {
  164. if (string.Equals(metricCode, "S1_L2_004", StringComparison.OrdinalIgnoreCase)) return "58%";
  165. if (current == null || target == null || target == 0) return "-";
  166. var ratio = string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
  167. ? target.Value / current.Value * 100m
  168. : current.Value / target.Value * 100m;
  169. return $"{decimal.Round(ratio, 0):0}%";
  170. }
  171. private static string BuildDiagnosisCallout(string? metricName, string status, string? gapLabel)
  172. {
  173. var name = string.IsNullOrWhiteSpace(metricName) ? "当前指标" : metricName;
  174. return status switch
  175. {
  176. "red" => $"{name} 未达标,期量差 {gapLabel ?? "-"},建议优先定位责任环节。",
  177. "yellow" => $"{name} 存在预警,期量差 {gapLabel ?? "-"},建议持续跟踪。",
  178. "green" => $"{name} 达标,可作为后续环节输入。",
  179. _ => $"{name} 暂无完整诊断数据。"
  180. };
  181. }
  182. private sealed record DiagnosisValue(decimal? Current, decimal? Target, string Status, string? GapLabel);
  183. }