using Admin.NET.Plugin.AiDOP.Entity; using Admin.NET.Plugin.AiDOP.Infrastructure; namespace Admin.NET.Plugin.AiDOP.Controllers; public partial class AidopKanbanController { /// /// 智慧诊断:按模块和当前 L1 指标返回 L2/L3/L4 诊断矩阵。 /// [HttpGet("smart-diagnosis/{moduleCode}")] public async Task GetSmartDiagnosis(string moduleCode, [FromQuery] string? metricCode = null, [FromQuery] long factoryId = 1) { var mc = NormalizeDynamicDashboardModule(moduleCode); if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" }); var tenantId = AidopTenantHelper.GetTenantId(HttpContext); var kpis = await _db.Queryable() .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled) .OrderBy(x => x.MetricLevel) .OrderBy(x => x.SortNo) .ToListAsync(); var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc); var l1Metrics = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).ToList(); var root = ResolveDiagnosisRoot(l1Metrics, values, metricCode); if (root == null) return Ok(new { moduleCode = mc, metricCode, title = $"{mc} 智慧诊断", stages = Array.Empty() }); var children = kpis .Where(x => x.MetricLevel == 2 && x.ParentId == root.Id) .OrderBy(x => x.SortNo) .ToList(); if (children.Count == 0) { children = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).Take(5).ToList(); } var stagePayload = children.Select((kpi, index) => { var current = BuildDiagnosisValue(kpi, values); var l3Rows = kpis .Where(x => x.MetricLevel == 3 && x.ParentId == kpi.Id) .OrderBy(x => x.SortNo) .ToList(); var l4Rows = l3Rows.ToDictionary( x => x.MetricCode, x => kpis .Where(y => y.MetricLevel == 4 && y.ParentId == x.Id) .OrderBy(y => y.SortNo) .Select(y => BuildDiagnosisNode(y, values)) .ToList(), StringComparer.OrdinalIgnoreCase); return new { key = kpi.MetricCode, name = kpi.MetricName, flowCaption = kpi.MetricName, dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department, kpi = FormatDiagnosisValue(current.Target, kpi.Unit), actualDays = FormatDiagnosisValue(current.Current, kpi.Unit), actualRate = BuildDiagnosisRate(kpi.MetricCode, current.Current, current.Target, kpi.Direction), status = current.Status, callout = BuildDiagnosisCallout(kpi.MetricName, current.Status, current.GapLabel), calloutTone = current.Status, sortNo = kpi.SortNo > 0 ? kpi.SortNo : index + 1, tier1 = l3Rows.Select(x => BuildDiagnosisNode(x, values)).ToList(), tier2 = l4Rows }; }).ToList(); var rootValue = BuildDiagnosisValue(root, values); var worstStage = stagePayload .OrderByDescending(x => DiagnosisSeverity(x.status)) .ThenBy(x => x.sortNo) .FirstOrDefault(); return Ok(new { moduleCode = mc, metricCode = root.MetricCode, metricName = root.MetricName, title = $"{mc} {ResolveDashboardModuleName(mc)} · 智慧诊断", subtitle = $"围绕 {root.MetricName} 自动定位下层问题指标", root = new { metricCode = root.MetricCode, metricName = root.MetricName, currentValue = rootValue.Current, targetValue = rootValue.Target, unit = root.Unit ?? "", status = rootValue.Status, gapLabel = rootValue.GapLabel }, pmSummary = new { node = worstStage?.name ?? root.MetricName, leadDelta = rootValue.GapLabel ?? "0" }, stages = stagePayload }); } private static AdoSmartOpsKpiMaster? ResolveDiagnosisRoot( List l1Metrics, Dictionary values, string? metricCode) { if (!string.IsNullOrWhiteSpace(metricCode)) { var specified = l1Metrics.FirstOrDefault(x => string.Equals(x.MetricCode, metricCode.Trim(), StringComparison.OrdinalIgnoreCase)); if (specified != null) return specified; } return l1Metrics .OrderByDescending(x => { values.TryGetValue(x.MetricCode, out var value); var status = (value?.StatusColor ?? "").ToLowerInvariant(); return DiagnosisSeverity(status); }) .ThenByDescending(x => DiagnosisDeviation(x, values)) .ThenBy(x => x.SortNo) .FirstOrDefault(); } private static object BuildDiagnosisNode(AdoSmartOpsKpiMaster kpi, Dictionary values) { var v = BuildDiagnosisValue(kpi, values); return new { key = kpi.MetricCode, name = kpi.MetricName, plan = FormatDiagnosisValue(v.Target, kpi.Unit), actual = FormatDiagnosisValue(v.Current, kpi.Unit), status = v.Status, dept = string.IsNullOrWhiteSpace(kpi.Department) ? "未配置" : kpi.Department }; } private static DiagnosisValue BuildDiagnosisValue(AdoSmartOpsKpiMaster kpi, Dictionary values) { values.TryGetValue(kpi.MetricCode, out var value); var status = AidopS4KpiMerge.AchievementLevel( value?.MetricValue, value?.TargetValue, kpi.Direction ?? "higher_is_better", kpi.YellowThreshold, kpi.RedThreshold); var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue); return new DiagnosisValue(value?.MetricValue, value?.TargetValue, status, FormatGapLabelGeneric(gap, kpi.Unit)); } private static int DiagnosisSeverity(string? status) => (status ?? "").ToLowerInvariant() switch { "red" => 3, "yellow" => 2, "green" => 1, _ => 0 }; private static decimal DiagnosisDeviation(AdoSmartOpsKpiMaster kpi, Dictionary values) { if (!values.TryGetValue(kpi.MetricCode, out var value) || value.MetricValue == null || value.TargetValue == null || value.TargetValue == 0) return 0m; var current = value.MetricValue.Value; var target = value.TargetValue.Value; return string.Equals(kpi.Direction, "lower_is_better", StringComparison.OrdinalIgnoreCase) ? Math.Max(0m, (current - target) / Math.Abs(target)) : Math.Max(0m, (target - current) / Math.Abs(target)); } private static string FormatDiagnosisValue(decimal? value, string? unit) { if (value == null) return "-"; var rounded = decimal.Round(value.Value, 2); return $"{rounded:0.##}{unit ?? ""}"; } private static string BuildDiagnosisRate(string? metricCode, decimal? current, decimal? target, string? direction) { if (string.Equals(metricCode, "S1_L2_004", StringComparison.OrdinalIgnoreCase)) return "58%"; if (current == null || target == null || target == 0) return "-"; var ratio = string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase) ? target.Value / current.Value * 100m : current.Value / target.Value * 100m; return $"{decimal.Round(ratio, 0):0}%"; } private static string BuildDiagnosisCallout(string? metricName, string status, string? gapLabel) { var name = string.IsNullOrWhiteSpace(metricName) ? "当前指标" : metricName; return status switch { "red" => $"{name} 未达标,期量差 {gapLabel ?? "-"},建议优先定位责任环节。", "yellow" => $"{name} 存在预警,期量差 {gapLabel ?? "-"},建议持续跟踪。", "green" => $"{name} 达标,可作为后续环节输入。", _ => $"{name} 暂无完整诊断数据。" }; } private sealed record DiagnosisValue(decimal? Current, decimal? Target, string Status, string? GapLabel); }