using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity; using Admin.NET.Plugin.AiDOP.Infrastructure; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.Controllers; public partial class AidopKanbanController { private static readonly HashSet DynamicDashboardModules = new(StringComparer.OrdinalIgnoreCase) { "S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9" }; /// /// 通用动态详情看板:返回页面区域、组件配置、指标定义、当前值和趋势。 /// [HttpGet("dashboard-page/{moduleCode}")] public async Task GetDashboardPage(string moduleCode, [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 kpiByCode = kpis.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase); var pageConfig = (await _db.Queryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc) .ToListAsync()).FirstOrDefault(); var persistedWidgets = await _db.Queryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1) .OrderBy(x => x.SortNo) .ToListAsync(); var widgets = persistedWidgets.Count > 0 ? persistedWidgets.Select(x => MapPersistedWidget(x, kpiByCode)).Where(x => x.MetricCodes.Count > 0).ToList() : BuildDefaultDashboardWidgets(mc, kpis); var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc); var sections = BuildDashboardSections(widgets, values, kpiByCode); var tabs = BuildDashboardTabs(kpis); var navigationMode = ResolveNavigationMode(pageConfig?.NavigationMode, tabs.Count, mc); return Ok(new { moduleCode = mc, title = $"{mc} {ResolveDashboardModuleName(mc)}", subtitle = "动态运营指标看板", layoutMode = "section_grid", navigationMode, tabSource = pageConfig?.TabSource ?? "l1_metrics", defaultTabMetricCode = pageConfig?.DefaultTabMetricCode ?? tabs.FirstOrDefault()?.MetricCode, tabs, dataSource = new { metricDefinition = "ado_smart_ops_kpi_master", valueTables = new[] { "ado_s9_kpi_value_l1_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l4_day" }, formulaStatus = "formula_expr_validated_pending_runtime_engine" }, metrics = kpis.Select(x => BuildDashboardMetricPayload(x, values)).ToList(), sections }); } /// /// 保存动态详情看板组件配置。前端配置页可逐步接入,当前 S1 默认布局无需先保存。 /// [HttpPut("dashboard-page/{moduleCode}")] public async Task PutDashboardPage(string moduleCode, [FromBody] DashboardPageSaveDto? body, [FromQuery] long factoryId = 1) { var mc = NormalizeDynamicDashboardModule(moduleCode); if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" }); if (body?.Widgets == null) return BadRequest(new { message = "widgets 不能为空" }); var tenantId = AidopTenantHelper.GetTenantId(HttpContext); var kpiCodes = await _db.Queryable() .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled) .Select(x => x.MetricCode) .ToListAsync(); var validCodes = new HashSet(kpiCodes, StringComparer.OrdinalIgnoreCase); var now = DateTime.Now; var rows = new List(); var sort = 0; foreach (var w in body.Widgets) { var metricCodes = (w.MetricCodes ?? new List()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (metricCodes.Count == 0) continue; var invalid = metricCodes.FirstOrDefault(x => !validCodes.Contains(x)); if (invalid != null) return BadRequest(new { message = $"无效指标编码: {invalid}" }); sort += 10; rows.Add(new AdoSmartOpsDashboardWidget { TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc, SectionCode = NormalizeSectionCode(w.SectionCode), TabMetricCode = string.IsNullOrWhiteSpace(w.TabMetricCode) ? null : w.TabMetricCode.Trim(), WidgetCode = string.IsNullOrWhiteSpace(w.WidgetCode) ? $"{mc}-W-{sort}" : w.WidgetCode.Trim(), WidgetType = NormalizeWidgetType(w.WidgetType), Title = string.IsNullOrWhiteSpace(w.Title) ? null : w.Title.Trim(), MetricLevel = Math.Max(1, Math.Min(4, w.MetricLevel)), MetricCodesJson = JsonSerializer.Serialize(metricCodes), Span = NormalizeSpan(w.Span), SortNo = w.SortNo > 0 ? w.SortNo : sort, StyleJson = string.IsNullOrWhiteSpace(w.StyleJson) ? null : w.StyleJson, IsEnabled = 1, UpdateTime = now }); } try { _db.Ado.BeginTran(); var navigationMode = NormalizeNavigationMode(body.NavigationMode); var tabSource = string.IsNullOrWhiteSpace(body.TabSource) ? "l1_metrics" : body.TabSource.Trim(); var defaultTab = string.IsNullOrWhiteSpace(body.DefaultTabMetricCode) ? null : body.DefaultTabMetricCode.Trim(); var pageRows = await _db.Updateable() .SetColumns(x => new AdoSmartOpsDashboardPageConfig { NavigationMode = navigationMode, TabSource = tabSource, DefaultTabMetricCode = defaultTab, Theme = "dark", UpdateTime = now }) .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc) .ExecuteCommandAsync(); if (pageRows == 0) { await _db.Insertable(new AdoSmartOpsDashboardPageConfig { TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc, NavigationMode = navigationMode, TabSource = tabSource, DefaultTabMetricCode = defaultTab, Theme = "dark", UpdateTime = now }).ExecuteCommandAsync(); } await _db.Deleteable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc) .ExecuteCommandAsync(); if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync(); _db.Ado.CommitTran(); } catch (Exception ex) { _db.Ado.RollbackTran(); return BadRequest(new { message = ex.Message }); } return Ok(new { ok = true, count = rows.Count }); } private static string? NormalizeDynamicDashboardModule(string moduleCode) { var mc = (moduleCode ?? "").Trim().ToUpperInvariant(); return DynamicDashboardModules.Contains(mc) ? mc : null; } private static string ResolveDashboardModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch { "S1" => "产销协同看板", "S2" => "订单排程看板", "S3" => "供应协同看板", "S4" => "采购执行看板", "S5" => "物料仓储看板", "S6" => "生产执行看板", "S7" => "成品仓储看板", "S9" => "运营指标看板", _ => "运营看板" }; private static string NormalizeSectionCode(string? sectionCode) { var s = (sectionCode ?? "").Trim().ToLowerInvariant(); return s is "overview" or "process_efficiency" or "quality_trend" or "resource_load" or "inventory_warning" or "breakdown" or "trend" or "detail" ? s : "overview"; } private static string NormalizeWidgetType(string? widgetType) { var s = (widgetType ?? "").Trim().ToLowerInvariant(); return s is "metric_card" or "traffic_light_card" or "progress_card" or "gauge" or "trend_line" or "line_trend" or "bar_compare" or "funnel_chart" or "heatmap" or "threshold_line" or "area_chart" or "metric_tree" or "metric_table" ? s : "metric_card"; } private static string NormalizeNavigationMode(string? navigationMode) { var s = (navigationMode ?? "").Trim().ToLowerInvariant(); return s is "tabs_by_l1" or "l1_cards" or "single_page" or "auto" ? s : "auto"; } private static string ResolveNavigationMode(string? navigationMode, int tabCount, string moduleCode) { var mode = NormalizeNavigationMode(navigationMode); if (mode != "auto") return mode; if (string.Equals(moduleCode, "S1", StringComparison.OrdinalIgnoreCase) && tabCount > 0) return "l1_cards"; return tabCount > 3 ? "tabs_by_l1" : "single_page"; } private static int NormalizeSpan(int span) { if (span <= 0) return 6; if (span > 24) return 24; return span; } private static DashboardWidgetModel MapPersistedWidget(AdoSmartOpsDashboardWidget row, Dictionary kpiByCode) { var codes = ParseMetricCodes(row.MetricCodesJson) .Where(kpiByCode.ContainsKey) .ToList(); return new DashboardWidgetModel( row.SectionCode, row.TabMetricCode, row.WidgetCode, row.WidgetType, string.IsNullOrWhiteSpace(row.Title) && codes.Count > 0 ? kpiByCode[codes[0]].MetricName : row.Title, row.MetricLevel, codes, row.Span, row.SortNo, row.StyleJson); } private static List BuildDefaultDashboardWidgets(string moduleCode, List kpis) { var result = new List(); var l1 = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).Take(8).ToList(); var l2 = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).ToList(); var sort = 0; foreach (var k in l1) { sort += 10; var widgetType = ResolveDefaultWidgetType(k); result.Add(new DashboardWidgetModel( "overview", k.MetricCode, $"{moduleCode}-OV-{k.MetricCode}", widgetType, k.MetricName, 1, new List { k.MetricCode }, widgetType == "gauge" ? 6 : 6, sort, null)); } foreach (var group in l2.GroupBy(x => x.ParentId).OrderBy(g => g.Min(x => x.SortNo))) { sort += 10; var metricCodes = group.OrderBy(x => x.SortNo).Select(x => x.MetricCode).ToList(); var parent = group.Key == null ? null : kpis.FirstOrDefault(x => x.Id == group.Key); var parentName = parent?.MetricName ?? "未分组指标"; var tabMetricCode = parent?.MetricCode; var sectionCode = ResolveDefaultSection(parentName); result.Add(new DashboardWidgetModel( sectionCode, tabMetricCode, $"{moduleCode}-BD-{group.Key ?? 0}", ResolveDefaultAnalysisWidgetType(parentName), parentName, 2, metricCodes, sectionCode == "process_efficiency" ? 12 : 8, sort, null)); } foreach (var k in l1) { sort += 10; result.Add(new DashboardWidgetModel( ResolveDefaultSection(k.MetricName), k.MetricCode, $"{moduleCode}-TR-{k.MetricCode}", ResolveDefaultTrendWidgetType(k.MetricName), $"{k.MetricName}趋势", 1, new List { k.MetricCode }, 12, sort, null)); } return result; } private static string ResolveDefaultWidgetType(AdoSmartOpsKpiMaster kpi) { var name = kpi.MetricName ?? ""; if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("合格率")) return "gauge"; if (name.Contains("周期") || name.Contains("周转")) return "progress_card"; return "metric_card"; } private static string ResolveDefaultSection(string? metricName) { var name = metricName ?? ""; if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("质量")) return "quality_trend"; if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "resource_load"; if (name.Contains("库存") || name.Contains("周转")) return "inventory_warning"; if (name.Contains("周期") || name.Contains("评审") || name.Contains("流程")) return "process_efficiency"; return "breakdown"; } private static string ResolveDefaultAnalysisWidgetType(string? metricName) { var name = metricName ?? ""; if (name.Contains("满足率") || name.Contains("达成率")) return "line_trend"; if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "heatmap"; if (name.Contains("库存") || name.Contains("周转")) return "threshold_line"; if (name.Contains("周期") || name.Contains("评审")) return "bar_compare"; return "metric_tree"; } private static string ResolveDefaultTrendWidgetType(string? metricName) { var name = metricName ?? ""; if (name.Contains("库存")) return "area_chart"; if (name.Contains("周转")) return "threshold_line"; return "line_trend"; } private async Task> LoadDashboardValuesAsync(long tenantId, long factoryId, string moduleCode) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); AidopTenantMigration.EnsureKpiValueL4Table(_db); foreach (var (table, level) in new[] { ("ado_s9_kpi_value_l1_day", 1), ("ado_s9_kpi_value_l2_day", 2), ("ado_s9_kpi_value_l3_day", 3), ("ado_s9_kpi_value_l4_day", 4) }) { var bizDate = await ResolveMaxBizDateGenericAsync(table, tenantId, factoryId, moduleCode); var currentSql = $""" SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date=@bizDate """; var rows = await _db.Ado.SqlQueryAsync(currentSql, new { tenantId, factoryId, moduleCode, bizDate }); var trendSql = $""" SELECT metric_code AS MetricCode, biz_date AS BizDate, metric_value AS MetricValue FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date >= DATE_SUB(@bizDate, INTERVAL 13 DAY) ORDER BY metric_code, biz_date """; var trends = await _db.Ado.SqlQueryAsync(trendSql, new { tenantId, factoryId, moduleCode, bizDate }); var trendByMetric = trends .Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)) .GroupBy(x => x.MetricCode!, StringComparer.OrdinalIgnoreCase) .ToDictionary( x => x.Key, x => x.Select(t => new DashboardTrendPointModel(t.BizDate.ToString("yyyy-MM-dd"), t.MetricValue)).ToList(), StringComparer.OrdinalIgnoreCase); foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode))) { result[row.MetricCode!] = new DashboardMetricValueModel( level, row.MetricValue, row.TargetValue, row.StatusColor, row.TrendFlag, trendByMetric.TryGetValue(row.MetricCode!, out var pts) ? pts : new List()); } } return result; } private static List BuildDashboardSections( List widgets, Dictionary values, Dictionary kpiByCode) { var sectionMeta = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["overview"] = ("核心指标", 10), ["process_efficiency"] = ("流程效率分析", 20), ["quality_trend"] = ("质量与满意度趋势", 30), ["resource_load"] = ("人效与资源负荷", 40), ["inventory_warning"] = ("库存预警", 50), ["breakdown"] = ("指标分解", 60), ["trend"] = ("趋势分析", 70), ["detail"] = ("明细列表", 80) }; return widgets .GroupBy(x => NormalizeSectionCode(x.SectionCode)) .OrderBy(x => sectionMeta.TryGetValue(x.Key, out var meta) ? meta.Sort : 99) .Select(g => { var meta = sectionMeta.TryGetValue(g.Key, out var m) ? m : (g.Key, 99); return new { sectionCode = g.Key, title = meta.Item1, layout = g.Key == "overview" ? "card_grid" : "panel_grid", widgets = g.OrderBy(x => x.SortNo).Select(w => BuildDashboardWidgetPayload(w, values, kpiByCode)).ToList() }; }) .Cast() .ToList(); } private static object BuildDashboardWidgetPayload( DashboardWidgetModel widget, Dictionary values, Dictionary kpiByCode) { var metrics = widget.MetricCodes .Where(kpiByCode.ContainsKey) .Select(code => BuildDashboardMetricPayload(kpiByCode[code], values)) .ToList(); return new { sectionCode = widget.SectionCode, tabMetricCode = widget.TabMetricCode, widgetCode = widget.WidgetCode, widgetType = widget.WidgetType, title = widget.Title, metricLevel = widget.MetricLevel, span = widget.Span, sortNo = widget.SortNo, style = ParseStyleJson(widget.StyleJson), metrics }; } private static object BuildDashboardMetricPayload( AdoSmartOpsKpiMaster kpi, Dictionary values) { values.TryGetValue(kpi.MetricCode, out var value); var level = value?.Level ?? kpi.MetricLevel; var achievement = 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 { id = kpi.Id, metricCode = kpi.MetricCode, metricName = kpi.MetricName, metricLevel = level, parentId = kpi.ParentId, unit = kpi.Unit ?? "", direction = kpi.Direction, formula = kpi.Formula, formulaExpr = kpi.FormulaExpr, formulaPreview = kpi.FormulaPreview, calcRule = kpi.CalcRule, dataSource = kpi.DataSource, department = kpi.Department, currentValue = value?.MetricValue, targetValue = value?.TargetValue, statusColor = achievement, isBlink = achievement == "red" && IsBeyondRedThreshold(value?.MetricValue, value?.TargetValue, kpi.Direction), trendFlag = value?.TrendFlag, gapValue = gap, gapLabel = FormatGapLabelGeneric(gap, kpi.Unit), trend = value?.Trend ?? new List() }; } private static List ParseMetricCodes(string? json) { if (string.IsNullOrWhiteSpace(json)) return new List(); try { return JsonSerializer.Deserialize>(json) ?? new List(); } catch { return json.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } } private static object ParseStyleJson(string? json) { if (string.IsNullOrWhiteSpace(json)) return new { }; try { return JsonSerializer.Deserialize(json) ?? new { }; } catch { return new { raw = json }; } } private static bool IsBeyondRedThreshold(decimal? current, decimal? target, string? direction) { if (current == null || target == null || target == 0) return false; var ratio = current.Value / target.Value * 100m; return string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase) ? ratio > 120m : ratio < 80m; } private static List BuildDashboardTabs(List kpis) { return kpis .Where(x => x.MetricLevel == 1) .OrderBy(x => x.SortNo) .Select(x => new DashboardTabModel(x.MetricCode, x.MetricName, x.SortNo)) .ToList(); } public class DashboardPageSaveDto { public string? NavigationMode { get; set; } public string? TabSource { get; set; } public string? DefaultTabMetricCode { get; set; } public List? Widgets { get; set; } } public class DashboardWidgetSaveDto { public string? SectionCode { get; set; } public string? TabMetricCode { get; set; } public string? WidgetCode { get; set; } public string? WidgetType { get; set; } public string? Title { get; set; } public int MetricLevel { get; set; } public List? MetricCodes { get; set; } public int Span { get; set; } public int SortNo { get; set; } public string? StyleJson { get; set; } } private sealed record DashboardWidgetModel( string SectionCode, string? TabMetricCode, string WidgetCode, string WidgetType, string? Title, int MetricLevel, List MetricCodes, int Span, int SortNo, string? StyleJson); private sealed record DashboardTabModel(string MetricCode, string Title, int SortNo); private sealed record DashboardMetricValueModel( int Level, decimal? MetricValue, decimal? TargetValue, string? StatusColor, string? TrendFlag, List Trend); private sealed record DashboardTrendPointModel(string Date, decimal? Value); private sealed class DashboardValueRow { public string? MetricCode { get; set; } public decimal? MetricValue { get; set; } public decimal? TargetValue { get; set; } public string? StatusColor { get; set; } public string? TrendFlag { get; set; } } private sealed class DashboardTrendRow { public string? MetricCode { get; set; } public DateTime BizDate { get; set; } public decimal? MetricValue { get; set; } } }