AidopKanbanController.Diagnosis.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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. resultStatus = rootValue.ResultStatus,
  113. resultReason = rootValue.ResultReason,
  114. unit = root.Unit ?? "",
  115. status = rootValue.Status,
  116. gapLabel = rootValue.GapLabel,
  117. department = string.IsNullOrWhiteSpace(root.Department) ? "未配置" : root.Department
  118. },
  119. pmSummary = new
  120. {
  121. node = worstStage?.name ?? root.MetricName,
  122. leadDelta = rootValue.GapLabel ?? "0"
  123. },
  124. stages = stagePayload
  125. });
  126. }
  127. /// <summary>
  128. /// 按当前问题指标查询显式绑定的可追溯业务依据。
  129. /// 未登记指标返回 unsupported_metric,不回退到模块级通用事实。
  130. /// </summary>
  131. [HttpGet("smart-diagnosis/{moduleCode}/evidence")]
  132. public async Task<IActionResult> GetSmartDiagnosisEvidence(
  133. string moduleCode,
  134. [FromQuery] string metricCode,
  135. [FromQuery] long factoryId = 1,
  136. [FromQuery] string? dateStart = null,
  137. [FromQuery] string? dateEnd = null,
  138. [FromQuery] string? product = null,
  139. [FromQuery] string? orderNo = null,
  140. [FromQuery] string? productionLine = null,
  141. [FromQuery] string? customer = null,
  142. [FromQuery] string? supplier = null,
  143. [FromQuery] string? material = null,
  144. [FromQuery] string? poNo = null,
  145. [FromQuery] string? warehouse = null,
  146. [FromQuery] string? workOrder = null,
  147. [FromQuery] string? equipment = null,
  148. [FromQuery] string? outboundNo = null)
  149. {
  150. var mc = NormalizeDynamicDashboardModule(moduleCode);
  151. if (mc == null)
  152. return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
  153. var normalizedMetric = (metricCode ?? "").Trim().ToUpperInvariant();
  154. if (normalizedMetric.Length == 0)
  155. return BadRequest(new { message = "metricCode 不能为空" });
  156. var scope = await _trustedScope.ResolveAsync(factoryId);
  157. var metricExists = await _db.Queryable<AdoSmartOpsKpiMaster>()
  158. .Where(x => x.TenantId == scope.TenantId
  159. && x.ModuleCode == mc
  160. && x.MetricCode == normalizedMetric
  161. && x.IsEnabled)
  162. .AnyAsync();
  163. if (!metricExists)
  164. return NotFound(new { message = "当前租户和模块下不存在该启用指标" });
  165. var filter = ParseDashboardFilter(
  166. dateStart, dateEnd, product, orderNo, productionLine,
  167. customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
  168. var evidence = await _evidence.QueryMetricAsync(
  169. scope.TenantId,
  170. scope.FactoryId,
  171. mc,
  172. normalizedMetric,
  173. filter);
  174. return Ok(evidence);
  175. }
  176. private async Task<IActionResult> GetSmartDiagnosisFromS8ExceptionsAsync(
  177. long factoryId,
  178. string? dateStart,
  179. string? dateEnd,
  180. string? orderNo)
  181. {
  182. var scope = await _trustedScope.ResolveAsync(factoryId);
  183. await _s8KpiSnapshot.RefreshAsync(scope.TenantId, scope.FactoryId);
  184. var filter = ParseDashboardFilter(
  185. dateStart, dateEnd, null, orderNo, null,
  186. null, null, null, null, null, null, null, null);
  187. var evidence = await _evidence.QueryAsync(scope.TenantId, scope.FactoryId, "S8", filter);
  188. var first = evidence.Items.FirstOrDefault();
  189. return Ok(new
  190. {
  191. moduleCode = "S8",
  192. metricCode = first == null ? null : "S8_EXCEPTION",
  193. metricName = first?.Title,
  194. title = "S8 异常监控 · 智慧诊断",
  195. subtitle = first == null ? "无可用 S8 异常佐证" : $"依据已存在异常:{first.ObjectCode}",
  196. selectionMode = "s8_exception",
  197. hasChildren = false,
  198. evidence,
  199. root = first == null ? null : new
  200. {
  201. metricCode = "S8_EXCEPTION",
  202. metricName = first.Title,
  203. currentValue = first.Contribution,
  204. targetValue = (decimal?)null,
  205. unit = "",
  206. status = first.Status,
  207. gapLabel = first.ObjectCode,
  208. department = "异常监控"
  209. },
  210. stages = Array.Empty<object>()
  211. });
  212. }
  213. private static object BuildDiagnosisNode(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  214. {
  215. var v = BuildDiagnosisValue(kpi, values);
  216. return new
  217. {
  218. key = kpi.MetricCode,
  219. name = kpi.MetricName,
  220. plan = FormatDiagnosisValue(v.Target, kpi.Unit),
  221. actual = FormatDiagnosisValue(v.Current, kpi.Unit),
  222. status = v.Status,
  223. dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department
  224. };
  225. }
  226. private static DiagnosisValue BuildDiagnosisValue(AdoSmartOpsKpiMaster kpi, Dictionary<string, DashboardMetricValueModel> values)
  227. {
  228. values.TryGetValue(kpi.MetricCode, out var value);
  229. var status = AidopS4KpiMerge.AchievementLevel(
  230. value?.MetricValue,
  231. value?.TargetValue,
  232. kpi.Direction ?? "higher_is_better",
  233. kpi.YellowThreshold,
  234. kpi.RedThreshold);
  235. var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
  236. return new DiagnosisValue(value?.MetricValue, value?.TargetValue, status, FormatGapLabelGeneric(gap, kpi.Unit), value?.ResultStatus, value?.ResultReason);
  237. }
  238. private static string FormatDiagnosisValue(decimal? value, string? unit)
  239. {
  240. if (value == null) return "-";
  241. var rounded = decimal.Round(value.Value, 2);
  242. return $"{rounded:0.##}{unit ?? ""}";
  243. }
  244. private static string BuildDiagnosisCallout(string? metricName, string status, string? gapLabel)
  245. {
  246. var name = string.IsNullOrWhiteSpace(metricName) ? "当前指标" : metricName;
  247. return status switch
  248. {
  249. "red" => $"{name} 未达标,期量差 {gapLabel ?? "-"},建议优先定位责任环节。",
  250. "yellow" => $"{name} 存在预警,期量差 {gapLabel ?? "-"},建议持续跟踪。",
  251. "green" => $"{name} 达标,可作为后续环节输入。",
  252. _ => $"{name} 暂无完整诊断数据。"
  253. };
  254. }
  255. private sealed record DiagnosisValue(decimal? Current, decimal? Target, string Status, string? GapLabel, string? ResultStatus = null, string? ResultReason = null);
  256. }