AidopKanbanController.Diagnosis.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. namespace Admin.NET.Plugin.AiDOP.Controllers;
  5. public partial class AidopKanbanController
  6. {
  7. /// <summary>
  8. /// 智慧诊断:按模块和当前 L1 指标返回 L2/L3/L4 诊断矩阵。
  9. /// </summary>
  10. [HttpGet("smart-diagnosis/{moduleCode}")]
  11. public async Task<IActionResult> GetSmartDiagnosis(
  12. string moduleCode,
  13. [FromQuery] string? metricCode = null,
  14. [FromQuery] long factoryId = 1,
  15. [FromQuery] string? dateStart = null,
  16. [FromQuery] string? dateEnd = null,
  17. [FromQuery] string? product = null,
  18. [FromQuery] string? orderNo = null,
  19. [FromQuery] string? productionLine = null,
  20. [FromQuery] string? customer = null,
  21. [FromQuery] string? supplier = null,
  22. [FromQuery] string? material = null,
  23. [FromQuery] string? poNo = null,
  24. [FromQuery] string? warehouse = null,
  25. [FromQuery] string? workOrder = null,
  26. [FromQuery] string? equipment = null,
  27. [FromQuery] string? outboundNo = null)
  28. {
  29. var requested = (moduleCode ?? "").Trim().ToUpperInvariant();
  30. if (requested == "S8")
  31. return await GetSmartDiagnosisFromS8ExceptionsAsync(factoryId, dateStart, dateEnd, orderNo);
  32. var mc = NormalizeDynamicDashboardModule(moduleCode);
  33. if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S8/S9" });
  34. var scope = await _trustedScope.ResolveAsync(factoryId);
  35. var tenantId = scope.TenantId;
  36. factoryId = scope.FactoryId;
  37. var filter = ParseDashboardFilter(
  38. dateStart, dateEnd, product, orderNo, productionLine,
  39. customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
  40. var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
  41. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  42. .OrderBy(x => x.MetricLevel)
  43. .OrderBy(x => x.SortNo)
  44. .ToListAsync();
  45. var valueBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpis);
  46. var values = valueBundle.Values;
  47. var snaps = values.ToDictionary(
  48. x => x.Key,
  49. x => new SmartDiagnosisTree.ValueSnap(x.Value.MetricValue, x.Value.TargetValue),
  50. StringComparer.OrdinalIgnoreCase);
  51. var l1Metrics = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).ToList();
  52. var resolved = SmartDiagnosisTree.ResolveRoot(l1Metrics, snaps, metricCode);
  53. if (resolved.Error != null)
  54. return BadRequest(new { message = resolved.Error });
  55. var root = resolved.Root;
  56. if (root == null)
  57. return Ok(new { moduleCode = mc, metricCode, title = $"{mc} 智慧诊断", stages = Array.Empty<object>(), hasChildren = false });
  58. var children = SmartDiagnosisTree.DirectChildren(kpis, root.Id, 2);
  59. var stagePayload = children.Select((kpi, index) =>
  60. {
  61. var current = BuildDiagnosisValue(kpi, values);
  62. var l3Rows = SmartDiagnosisTree.DirectChildren(kpis, kpi.Id, 3);
  63. var l4Rows = l3Rows.ToDictionary(
  64. x => x.MetricCode,
  65. x => SmartDiagnosisTree.DirectChildren(kpis, x.Id, 4)
  66. .Select(y => BuildDiagnosisNode(y, values))
  67. .ToList(),
  68. StringComparer.OrdinalIgnoreCase);
  69. return new
  70. {
  71. key = kpi.MetricCode,
  72. name = kpi.MetricName,
  73. flowCaption = kpi.MetricName,
  74. dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department,
  75. kpi = FormatDiagnosisValue(current.Target, kpi.Unit),
  76. actualDays = FormatDiagnosisValue(current.Current, kpi.Unit),
  77. actualRate = SmartDiagnosisTree.BuildDiagnosisRate(current.Current, current.Target, kpi.Direction),
  78. status = current.Status,
  79. callout = BuildDiagnosisCallout(kpi.MetricName, current.Status, current.GapLabel),
  80. calloutTone = current.Status,
  81. sortNo = kpi.SortNo > 0 ? kpi.SortNo : index + 1,
  82. tier1 = l3Rows.Select(x => BuildDiagnosisNode(x, values)).ToList(),
  83. tier2 = l4Rows
  84. };
  85. }).ToList();
  86. var rootValue = BuildDiagnosisValue(root, values);
  87. var worstStage = stagePayload
  88. .OrderByDescending(x => SmartDiagnosisTree.DiagnosisSeverity(x.status))
  89. .ThenBy(x => x.sortNo)
  90. .FirstOrDefault();
  91. var selectionLabel = resolved.SelectionMode == SmartDiagnosisTree.SelectionGlobalWorst
  92. ? $"全局自动选择:{root.MetricName}"
  93. : $"当前指标:{root.MetricName}";
  94. var evidence = await _evidence.QueryAsync(tenantId, factoryId, mc, filter);
  95. return Ok(new
  96. {
  97. moduleCode = mc,
  98. metricCode = root.MetricCode,
  99. metricName = root.MetricName,
  100. title = $"{mc} {ResolveDashboardModuleName(mc)} · 智慧诊断",
  101. subtitle = selectionLabel,
  102. selectionMode = resolved.SelectionMode,
  103. hasChildren = children.Count > 0,
  104. dataSource = BuildDataSourcePayload(valueBundle),
  105. evidence,
  106. root = new
  107. {
  108. metricCode = root.MetricCode,
  109. metricName = root.MetricName,
  110. currentValue = rootValue.Current,
  111. targetValue = rootValue.Target,
  112. unit = root.Unit ?? "",
  113. status = rootValue.Status,
  114. gapLabel = rootValue.GapLabel,
  115. department = string.IsNullOrWhiteSpace(root.Department) ? "未配置" : root.Department
  116. },
  117. pmSummary = new
  118. {
  119. node = worstStage?.name ?? root.MetricName,
  120. leadDelta = rootValue.GapLabel ?? "0"
  121. },
  122. stages = stagePayload
  123. });
  124. }
  125. /// <summary>
  126. /// 按当前问题指标查询显式绑定的可追溯业务依据。
  127. /// 未登记指标返回 unsupported_metric,不回退到模块级通用事实。
  128. /// </summary>
  129. [HttpGet("smart-diagnosis/{moduleCode}/evidence")]
  130. public async Task<IActionResult> GetSmartDiagnosisEvidence(
  131. string moduleCode,
  132. [FromQuery] string metricCode,
  133. [FromQuery] long factoryId = 1,
  134. [FromQuery] string? dateStart = null,
  135. [FromQuery] string? dateEnd = null,
  136. [FromQuery] string? product = null,
  137. [FromQuery] string? orderNo = null,
  138. [FromQuery] string? productionLine = null,
  139. [FromQuery] string? customer = null,
  140. [FromQuery] string? supplier = null,
  141. [FromQuery] string? material = null,
  142. [FromQuery] string? poNo = null,
  143. [FromQuery] string? warehouse = null,
  144. [FromQuery] string? workOrder = null,
  145. [FromQuery] string? equipment = null,
  146. [FromQuery] string? outboundNo = null)
  147. {
  148. var mc = NormalizeDynamicDashboardModule(moduleCode);
  149. if (mc == null)
  150. return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
  151. var normalizedMetric = (metricCode ?? "").Trim().ToUpperInvariant();
  152. if (normalizedMetric.Length == 0)
  153. return BadRequest(new { message = "metricCode 不能为空" });
  154. var scope = await _trustedScope.ResolveAsync(factoryId);
  155. var metricExists = await _db.Queryable<AdoSmartOpsKpiMaster>()
  156. .Where(x => x.TenantId == scope.TenantId
  157. && x.ModuleCode == mc
  158. && x.MetricCode == normalizedMetric
  159. && x.IsEnabled)
  160. .AnyAsync();
  161. if (!metricExists)
  162. return NotFound(new { message = "当前租户和模块下不存在该启用指标" });
  163. var filter = ParseDashboardFilter(
  164. dateStart, dateEnd, product, orderNo, productionLine,
  165. customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
  166. var evidence = await _evidence.QueryMetricAsync(
  167. scope.TenantId,
  168. scope.FactoryId,
  169. mc,
  170. normalizedMetric,
  171. filter);
  172. return Ok(evidence);
  173. }
  174. private async Task<IActionResult> GetSmartDiagnosisFromS8ExceptionsAsync(
  175. long factoryId,
  176. string? dateStart,
  177. string? dateEnd,
  178. string? orderNo)
  179. {
  180. var scope = await _trustedScope.ResolveAsync(factoryId);
  181. await _s8KpiSnapshot.RefreshAsync(scope.TenantId, scope.FactoryId);
  182. var filter = ParseDashboardFilter(
  183. dateStart, dateEnd, null, orderNo, null,
  184. null, null, null, null, null, null, null, null);
  185. var evidence = await _evidence.QueryAsync(scope.TenantId, scope.FactoryId, "S8", filter);
  186. var first = evidence.Items.FirstOrDefault();
  187. return Ok(new
  188. {
  189. moduleCode = "S8",
  190. metricCode = first == null ? null : "S8_EXCEPTION",
  191. metricName = first?.Title,
  192. title = "S8 异常监控 · 智慧诊断",
  193. subtitle = first == null ? "无可用 S8 异常佐证" : $"依据已存在异常:{first.ObjectCode}",
  194. selectionMode = "s8_exception",
  195. hasChildren = false,
  196. evidence,
  197. root = first == null ? null : new
  198. {
  199. metricCode = "S8_EXCEPTION",
  200. metricName = first.Title,
  201. currentValue = first.Contribution,
  202. targetValue = (decimal?)null,
  203. unit = "",
  204. status = first.Status,
  205. gapLabel = first.ObjectCode,
  206. department = "异常监控"
  207. },
  208. stages = Array.Empty<object>()
  209. });
  210. }
  211. private static object BuildDiagnosisNode(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  212. {
  213. var v = BuildDiagnosisValue(kpi, values);
  214. return new
  215. {
  216. key = kpi.MetricCode,
  217. name = kpi.MetricName,
  218. plan = FormatDiagnosisValue(v.Target, kpi.Unit),
  219. actual = FormatDiagnosisValue(v.Current, kpi.Unit),
  220. status = v.Status,
  221. dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department
  222. };
  223. }
  224. private static DiagnosisValue BuildDiagnosisValue(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  225. {
  226. values.TryGetValue(kpi.MetricCode, out var value);
  227. var status = AidopS4KpiMerge.AchievementLevel(
  228. value?.MetricValue,
  229. value?.TargetValue,
  230. kpi.Direction ?? "higher_is_better",
  231. kpi.YellowThreshold,
  232. kpi.RedThreshold);
  233. var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
  234. return new DiagnosisValue(value?.MetricValue, value?.TargetValue, status, FormatGapLabelGeneric(gap, kpi.Unit));
  235. }
  236. private static string FormatDiagnosisValue(decimal? value, string? unit)
  237. {
  238. if (value == null) return "-";
  239. var rounded = decimal.Round(value.Value, 2);
  240. return $"{rounded:0.##}{unit ?? ""}";
  241. }
  242. private static string BuildDiagnosisCallout(string? metricName, string status, string? gapLabel)
  243. {
  244. var name = string.IsNullOrWhiteSpace(metricName) ? "当前指标" : metricName;
  245. return status switch
  246. {
  247. "red" => $"{name} 未达标,期量差 {gapLabel ?? "-"},建议优先定位责任环节。",
  248. "yellow" => $"{name} 存在预警,期量差 {gapLabel ?? "-"},建议持续跟踪。",
  249. "green" => $"{name} 达标,可作为后续环节输入。",
  250. _ => $"{name} 暂无完整诊断数据。"
  251. };
  252. }
  253. private sealed record DiagnosisValue(decimal? Current, decimal? Target, string Status, string? GapLabel);
  254. }