AidopKanbanController.DashboardPage.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Entity;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using SqlSugar;
  5. namespace Admin.NET.Plugin.AiDOP.Controllers;
  6. public partial class AidopKanbanController
  7. {
  8. private static readonly HashSet<string> DynamicDashboardModules = new(StringComparer.OrdinalIgnoreCase)
  9. {
  10. "S1", "S2", "S3", "S4", "S5", "S6", "S7", "S9"
  11. };
  12. /// <summary>
  13. /// 通用动态详情看板:返回页面区域、组件配置、指标定义、当前值和趋势。
  14. /// </summary>
  15. [HttpGet("dashboard-page/{moduleCode}")]
  16. public async Task<IActionResult> GetDashboardPage(string moduleCode, [FromQuery] long factoryId = 1)
  17. {
  18. var mc = NormalizeDynamicDashboardModule(moduleCode);
  19. if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
  20. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  21. var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
  22. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  23. .OrderBy(x => x.MetricLevel)
  24. .OrderBy(x => x.SortNo)
  25. .ToListAsync();
  26. var kpiByCode = kpis.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  27. var pageConfig = (await _db.Queryable<AdoSmartOpsDashboardPageConfig>()
  28. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  29. .ToListAsync()).FirstOrDefault();
  30. var persistedWidgets = await _db.Queryable<AdoSmartOpsDashboardWidget>()
  31. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1)
  32. .OrderBy(x => x.SortNo)
  33. .ToListAsync();
  34. var widgets = persistedWidgets.Count > 0
  35. ? persistedWidgets.Select(x => MapPersistedWidget(x, kpiByCode)).Where(x => x.MetricCodes.Count > 0).ToList()
  36. : BuildDefaultDashboardWidgets(mc, kpis);
  37. var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
  38. var sections = BuildDashboardSections(widgets, values, kpiByCode);
  39. var tabs = BuildDashboardTabs(kpis);
  40. var navigationMode = ResolveNavigationMode(pageConfig?.NavigationMode, tabs.Count, mc);
  41. return Ok(new
  42. {
  43. moduleCode = mc,
  44. title = $"{mc} {ResolveDashboardModuleName(mc)}",
  45. subtitle = "动态运营指标看板",
  46. layoutMode = "section_grid",
  47. navigationMode,
  48. tabSource = pageConfig?.TabSource ?? "l1_metrics",
  49. defaultTabMetricCode = pageConfig?.DefaultTabMetricCode ?? tabs.FirstOrDefault()?.MetricCode,
  50. tabs,
  51. dataSource = new
  52. {
  53. metricDefinition = "ado_smart_ops_kpi_master",
  54. 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" },
  55. formulaStatus = "formula_expr_validated_pending_runtime_engine"
  56. },
  57. metrics = kpis.Select(x => BuildDashboardMetricPayload(x, values)).ToList(),
  58. sections
  59. });
  60. }
  61. /// <summary>
  62. /// 保存动态详情看板组件配置。前端配置页可逐步接入,当前 S1 默认布局无需先保存。
  63. /// </summary>
  64. [HttpPut("dashboard-page/{moduleCode}")]
  65. public async Task<IActionResult> PutDashboardPage(string moduleCode, [FromBody] DashboardPageSaveDto? body, [FromQuery] long factoryId = 1)
  66. {
  67. var mc = NormalizeDynamicDashboardModule(moduleCode);
  68. if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
  69. if (body?.Widgets == null) return BadRequest(new { message = "widgets 不能为空" });
  70. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  71. var kpiCodes = await _db.Queryable<AdoSmartOpsKpiMaster>()
  72. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  73. .Select(x => x.MetricCode)
  74. .ToListAsync();
  75. var validCodes = new HashSet<string>(kpiCodes, StringComparer.OrdinalIgnoreCase);
  76. var now = DateTime.Now;
  77. var rows = new List<AdoSmartOpsDashboardWidget>();
  78. var sort = 0;
  79. foreach (var w in body.Widgets)
  80. {
  81. var metricCodes = (w.MetricCodes ?? new List<string>())
  82. .Where(x => !string.IsNullOrWhiteSpace(x))
  83. .Select(x => x.Trim())
  84. .Distinct(StringComparer.OrdinalIgnoreCase)
  85. .ToList();
  86. if (metricCodes.Count == 0) continue;
  87. var invalid = metricCodes.FirstOrDefault(x => !validCodes.Contains(x));
  88. if (invalid != null) return BadRequest(new { message = $"无效指标编码: {invalid}" });
  89. sort += 10;
  90. rows.Add(new AdoSmartOpsDashboardWidget
  91. {
  92. TenantId = tenantId,
  93. FactoryId = factoryId,
  94. ModuleCode = mc,
  95. SectionCode = NormalizeSectionCode(w.SectionCode),
  96. TabMetricCode = string.IsNullOrWhiteSpace(w.TabMetricCode) ? null : w.TabMetricCode.Trim(),
  97. WidgetCode = string.IsNullOrWhiteSpace(w.WidgetCode) ? $"{mc}-W-{sort}" : w.WidgetCode.Trim(),
  98. WidgetType = NormalizeWidgetType(w.WidgetType),
  99. Title = string.IsNullOrWhiteSpace(w.Title) ? null : w.Title.Trim(),
  100. MetricLevel = Math.Max(1, Math.Min(4, w.MetricLevel)),
  101. MetricCodesJson = JsonSerializer.Serialize(metricCodes),
  102. Span = NormalizeSpan(w.Span),
  103. SortNo = w.SortNo > 0 ? w.SortNo : sort,
  104. StyleJson = string.IsNullOrWhiteSpace(w.StyleJson) ? null : w.StyleJson,
  105. IsEnabled = 1,
  106. UpdateTime = now
  107. });
  108. }
  109. try
  110. {
  111. _db.Ado.BeginTran();
  112. var navigationMode = NormalizeNavigationMode(body.NavigationMode);
  113. var tabSource = string.IsNullOrWhiteSpace(body.TabSource) ? "l1_metrics" : body.TabSource.Trim();
  114. var defaultTab = string.IsNullOrWhiteSpace(body.DefaultTabMetricCode) ? null : body.DefaultTabMetricCode.Trim();
  115. var pageRows = await _db.Updateable<AdoSmartOpsDashboardPageConfig>()
  116. .SetColumns(x => new AdoSmartOpsDashboardPageConfig
  117. {
  118. NavigationMode = navigationMode,
  119. TabSource = tabSource,
  120. DefaultTabMetricCode = defaultTab,
  121. Theme = "dark",
  122. UpdateTime = now
  123. })
  124. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  125. .ExecuteCommandAsync();
  126. if (pageRows == 0)
  127. {
  128. await _db.Insertable(new AdoSmartOpsDashboardPageConfig
  129. {
  130. TenantId = tenantId,
  131. FactoryId = factoryId,
  132. ModuleCode = mc,
  133. NavigationMode = navigationMode,
  134. TabSource = tabSource,
  135. DefaultTabMetricCode = defaultTab,
  136. Theme = "dark",
  137. UpdateTime = now
  138. }).ExecuteCommandAsync();
  139. }
  140. await _db.Deleteable<AdoSmartOpsDashboardWidget>()
  141. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  142. .ExecuteCommandAsync();
  143. if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync();
  144. _db.Ado.CommitTran();
  145. }
  146. catch (Exception ex)
  147. {
  148. _db.Ado.RollbackTran();
  149. return BadRequest(new { message = ex.Message });
  150. }
  151. return Ok(new { ok = true, count = rows.Count });
  152. }
  153. private static string? NormalizeDynamicDashboardModule(string moduleCode)
  154. {
  155. var mc = (moduleCode ?? "").Trim().ToUpperInvariant();
  156. return DynamicDashboardModules.Contains(mc) ? mc : null;
  157. }
  158. private static string ResolveDashboardModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch
  159. {
  160. "S1" => "产销协同看板",
  161. "S2" => "订单排程看板",
  162. "S3" => "供应协同看板",
  163. "S4" => "采购执行看板",
  164. "S5" => "物料仓储看板",
  165. "S6" => "生产执行看板",
  166. "S7" => "成品仓储看板",
  167. "S9" => "运营指标看板",
  168. _ => "运营看板"
  169. };
  170. private static string NormalizeSectionCode(string? sectionCode)
  171. {
  172. var s = (sectionCode ?? "").Trim().ToLowerInvariant();
  173. return s is "overview" or "process_efficiency" or "quality_trend" or "resource_load"
  174. or "inventory_warning" or "breakdown" or "trend" or "detail" ? s : "overview";
  175. }
  176. private static string NormalizeWidgetType(string? widgetType)
  177. {
  178. var s = (widgetType ?? "").Trim().ToLowerInvariant();
  179. return s is "metric_card" or "traffic_light_card" or "progress_card" or "gauge"
  180. or "trend_line" or "line_trend" or "bar_compare" or "funnel_chart" or "heatmap"
  181. or "threshold_line" or "area_chart" or "metric_tree" or "metric_table" ? s : "metric_card";
  182. }
  183. private static string NormalizeNavigationMode(string? navigationMode)
  184. {
  185. var s = (navigationMode ?? "").Trim().ToLowerInvariant();
  186. return s is "tabs_by_l1" or "l1_cards" or "single_page" or "auto" ? s : "auto";
  187. }
  188. private static string ResolveNavigationMode(string? navigationMode, int tabCount, string moduleCode)
  189. {
  190. var mode = NormalizeNavigationMode(navigationMode);
  191. if (mode != "auto") return mode;
  192. if (string.Equals(moduleCode, "S1", StringComparison.OrdinalIgnoreCase) && tabCount > 0) return "l1_cards";
  193. return tabCount > 3 ? "tabs_by_l1" : "single_page";
  194. }
  195. private static int NormalizeSpan(int span)
  196. {
  197. if (span <= 0) return 6;
  198. if (span > 24) return 24;
  199. return span;
  200. }
  201. private static DashboardWidgetModel MapPersistedWidget(AdoSmartOpsDashboardWidget row, Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
  202. {
  203. var codes = ParseMetricCodes(row.MetricCodesJson)
  204. .Where(kpiByCode.ContainsKey)
  205. .ToList();
  206. return new DashboardWidgetModel(
  207. row.SectionCode,
  208. row.TabMetricCode,
  209. row.WidgetCode,
  210. row.WidgetType,
  211. string.IsNullOrWhiteSpace(row.Title) && codes.Count > 0 ? kpiByCode[codes[0]].MetricName : row.Title,
  212. row.MetricLevel,
  213. codes,
  214. row.Span,
  215. row.SortNo,
  216. row.StyleJson);
  217. }
  218. private static List<DashboardWidgetModel> BuildDefaultDashboardWidgets(string moduleCode, List<AdoSmartOpsKpiMaster> kpis)
  219. {
  220. var result = new List<DashboardWidgetModel>();
  221. var l1 = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).Take(8).ToList();
  222. var l2 = kpis.Where(x => x.MetricLevel == 2).OrderBy(x => x.SortNo).ToList();
  223. var sort = 0;
  224. foreach (var k in l1)
  225. {
  226. sort += 10;
  227. var widgetType = ResolveDefaultWidgetType(k);
  228. result.Add(new DashboardWidgetModel(
  229. "overview",
  230. k.MetricCode,
  231. $"{moduleCode}-OV-{k.MetricCode}",
  232. widgetType,
  233. k.MetricName,
  234. 1,
  235. new List<string> { k.MetricCode },
  236. widgetType == "gauge" ? 6 : 6,
  237. sort,
  238. null));
  239. }
  240. foreach (var group in l2.GroupBy(x => x.ParentId).OrderBy(g => g.Min(x => x.SortNo)))
  241. {
  242. sort += 10;
  243. var metricCodes = group.OrderBy(x => x.SortNo).Select(x => x.MetricCode).ToList();
  244. var parent = group.Key == null ? null : kpis.FirstOrDefault(x => x.Id == group.Key);
  245. var parentName = parent?.MetricName ?? "未分组指标";
  246. var tabMetricCode = parent?.MetricCode;
  247. var sectionCode = ResolveDefaultSection(parentName);
  248. result.Add(new DashboardWidgetModel(
  249. sectionCode,
  250. tabMetricCode,
  251. $"{moduleCode}-BD-{group.Key ?? 0}",
  252. ResolveDefaultAnalysisWidgetType(parentName),
  253. parentName,
  254. 2,
  255. metricCodes,
  256. sectionCode == "process_efficiency" ? 12 : 8,
  257. sort,
  258. null));
  259. }
  260. foreach (var k in l1)
  261. {
  262. sort += 10;
  263. result.Add(new DashboardWidgetModel(
  264. ResolveDefaultSection(k.MetricName),
  265. k.MetricCode,
  266. $"{moduleCode}-TR-{k.MetricCode}",
  267. ResolveDefaultTrendWidgetType(k.MetricName),
  268. $"{k.MetricName}趋势",
  269. 1,
  270. new List<string> { k.MetricCode },
  271. 12,
  272. sort,
  273. null));
  274. }
  275. return result;
  276. }
  277. private static string ResolveDefaultWidgetType(AdoSmartOpsKpiMaster kpi)
  278. {
  279. var name = kpi.MetricName ?? "";
  280. if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("合格率")) return "gauge";
  281. if (name.Contains("周期") || name.Contains("周转")) return "progress_card";
  282. return "metric_card";
  283. }
  284. private static string ResolveDefaultSection(string? metricName)
  285. {
  286. var name = metricName ?? "";
  287. if (name.Contains("满足率") || name.Contains("达成率") || name.Contains("质量")) return "quality_trend";
  288. if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "resource_load";
  289. if (name.Contains("库存") || name.Contains("周转")) return "inventory_warning";
  290. if (name.Contains("周期") || name.Contains("评审") || name.Contains("流程")) return "process_efficiency";
  291. return "breakdown";
  292. }
  293. private static string ResolveDefaultAnalysisWidgetType(string? metricName)
  294. {
  295. var name = metricName ?? "";
  296. if (name.Contains("满足率") || name.Contains("达成率")) return "line_trend";
  297. if (name.Contains("人数") || name.Contains("人效") || name.Contains("负荷")) return "heatmap";
  298. if (name.Contains("库存") || name.Contains("周转")) return "threshold_line";
  299. if (name.Contains("周期") || name.Contains("评审")) return "bar_compare";
  300. return "metric_tree";
  301. }
  302. private static string ResolveDefaultTrendWidgetType(string? metricName)
  303. {
  304. var name = metricName ?? "";
  305. if (name.Contains("库存")) return "area_chart";
  306. if (name.Contains("周转")) return "threshold_line";
  307. return "line_trend";
  308. }
  309. private async Task<Dictionary<string, DashboardMetricValueModel>> LoadDashboardValuesAsync(long tenantId, long factoryId, string moduleCode)
  310. {
  311. var result = new Dictionary<string, DashboardMetricValueModel>(StringComparer.OrdinalIgnoreCase);
  312. AidopTenantMigration.EnsureKpiValueL4Table(_db);
  313. foreach (var (table, level) in new[]
  314. {
  315. ("ado_s9_kpi_value_l1_day", 1),
  316. ("ado_s9_kpi_value_l2_day", 2),
  317. ("ado_s9_kpi_value_l3_day", 3),
  318. ("ado_s9_kpi_value_l4_day", 4)
  319. })
  320. {
  321. var bizDate = await ResolveMaxBizDateGenericAsync(table, tenantId, factoryId, moduleCode);
  322. var currentSql = $"""
  323. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
  324. FROM {table}
  325. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date=@bizDate
  326. """;
  327. var rows = await _db.Ado.SqlQueryAsync<DashboardValueRow>(currentSql, new { tenantId, factoryId, moduleCode, bizDate });
  328. var trendSql = $"""
  329. SELECT metric_code AS MetricCode, biz_date AS BizDate, metric_value AS MetricValue
  330. FROM {table}
  331. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0
  332. AND biz_date >= DATE_SUB(@bizDate, INTERVAL 13 DAY)
  333. ORDER BY metric_code, biz_date
  334. """;
  335. var trends = await _db.Ado.SqlQueryAsync<DashboardTrendRow>(trendSql, new { tenantId, factoryId, moduleCode, bizDate });
  336. var trendByMetric = trends
  337. .Where(x => !string.IsNullOrWhiteSpace(x.MetricCode))
  338. .GroupBy(x => x.MetricCode!, StringComparer.OrdinalIgnoreCase)
  339. .ToDictionary(
  340. x => x.Key,
  341. x => x.Select(t => new DashboardTrendPointModel(t.BizDate.ToString("yyyy-MM-dd"), t.MetricValue)).ToList(),
  342. StringComparer.OrdinalIgnoreCase);
  343. foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)))
  344. {
  345. result[row.MetricCode!] = new DashboardMetricValueModel(
  346. level,
  347. row.MetricValue,
  348. row.TargetValue,
  349. row.StatusColor,
  350. row.TrendFlag,
  351. trendByMetric.TryGetValue(row.MetricCode!, out var pts) ? pts : new List<DashboardTrendPointModel>());
  352. }
  353. }
  354. return result;
  355. }
  356. private static List<object> BuildDashboardSections(
  357. List<DashboardWidgetModel> widgets,
  358. Dictionary<string, DashboardMetricValueModel> values,
  359. Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
  360. {
  361. var sectionMeta = new Dictionary<string, (string Title, int Sort)>(StringComparer.OrdinalIgnoreCase)
  362. {
  363. ["overview"] = ("核心指标", 10),
  364. ["process_efficiency"] = ("流程效率分析", 20),
  365. ["quality_trend"] = ("质量与满意度趋势", 30),
  366. ["resource_load"] = ("人效与资源负荷", 40),
  367. ["inventory_warning"] = ("库存预警", 50),
  368. ["breakdown"] = ("指标分解", 60),
  369. ["trend"] = ("趋势分析", 70),
  370. ["detail"] = ("明细列表", 80)
  371. };
  372. return widgets
  373. .GroupBy(x => NormalizeSectionCode(x.SectionCode))
  374. .OrderBy(x => sectionMeta.TryGetValue(x.Key, out var meta) ? meta.Sort : 99)
  375. .Select(g =>
  376. {
  377. var meta = sectionMeta.TryGetValue(g.Key, out var m) ? m : (g.Key, 99);
  378. return new
  379. {
  380. sectionCode = g.Key,
  381. title = meta.Item1,
  382. layout = g.Key == "overview" ? "card_grid" : "panel_grid",
  383. widgets = g.OrderBy(x => x.SortNo).Select(w => BuildDashboardWidgetPayload(w, values, kpiByCode)).ToList()
  384. };
  385. })
  386. .Cast<object>()
  387. .ToList();
  388. }
  389. private static object BuildDashboardWidgetPayload(
  390. DashboardWidgetModel widget,
  391. Dictionary<string, DashboardMetricValueModel> values,
  392. Dictionary<string, AdoSmartOpsKpiMaster> kpiByCode)
  393. {
  394. var metrics = widget.MetricCodes
  395. .Where(kpiByCode.ContainsKey)
  396. .Select(code => BuildDashboardMetricPayload(kpiByCode[code], values))
  397. .ToList();
  398. return new
  399. {
  400. sectionCode = widget.SectionCode,
  401. tabMetricCode = widget.TabMetricCode,
  402. widgetCode = widget.WidgetCode,
  403. widgetType = widget.WidgetType,
  404. title = widget.Title,
  405. metricLevel = widget.MetricLevel,
  406. span = widget.Span,
  407. sortNo = widget.SortNo,
  408. style = ParseStyleJson(widget.StyleJson),
  409. metrics
  410. };
  411. }
  412. private static object BuildDashboardMetricPayload(
  413. AdoSmartOpsKpiMaster kpi,
  414. Dictionary<string, DashboardMetricValueModel> values)
  415. {
  416. values.TryGetValue(kpi.MetricCode, out var value);
  417. var level = value?.Level ?? kpi.MetricLevel;
  418. var achievement = AidopS4KpiMerge.AchievementLevel(
  419. value?.MetricValue,
  420. value?.TargetValue,
  421. kpi.Direction ?? "higher_is_better",
  422. kpi.YellowThreshold,
  423. kpi.RedThreshold);
  424. var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
  425. return new
  426. {
  427. id = kpi.Id,
  428. metricCode = kpi.MetricCode,
  429. metricName = kpi.MetricName,
  430. metricLevel = level,
  431. parentId = kpi.ParentId,
  432. unit = kpi.Unit ?? "",
  433. direction = kpi.Direction,
  434. formula = kpi.Formula,
  435. formulaExpr = kpi.FormulaExpr,
  436. formulaPreview = kpi.FormulaPreview,
  437. calcRule = kpi.CalcRule,
  438. dataSource = kpi.DataSource,
  439. department = kpi.Department,
  440. currentValue = value?.MetricValue,
  441. targetValue = value?.TargetValue,
  442. statusColor = achievement,
  443. isBlink = achievement == "red" && IsBeyondRedThreshold(value?.MetricValue, value?.TargetValue, kpi.Direction),
  444. trendFlag = value?.TrendFlag,
  445. gapValue = gap,
  446. gapLabel = FormatGapLabelGeneric(gap, kpi.Unit),
  447. trend = value?.Trend ?? new List<DashboardTrendPointModel>()
  448. };
  449. }
  450. private static List<string> ParseMetricCodes(string? json)
  451. {
  452. if (string.IsNullOrWhiteSpace(json)) return new List<string>();
  453. try
  454. {
  455. return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
  456. }
  457. catch
  458. {
  459. return json.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
  460. }
  461. }
  462. private static object ParseStyleJson(string? json)
  463. {
  464. if (string.IsNullOrWhiteSpace(json)) return new { };
  465. try
  466. {
  467. return JsonSerializer.Deserialize<object>(json) ?? new { };
  468. }
  469. catch
  470. {
  471. return new { raw = json };
  472. }
  473. }
  474. private static bool IsBeyondRedThreshold(decimal? current, decimal? target, string? direction)
  475. {
  476. if (current == null || target == null || target == 0) return false;
  477. var ratio = current.Value / target.Value * 100m;
  478. return string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase)
  479. ? ratio > 120m
  480. : ratio < 80m;
  481. }
  482. private static List<DashboardTabModel> BuildDashboardTabs(List<AdoSmartOpsKpiMaster> kpis)
  483. {
  484. return kpis
  485. .Where(x => x.MetricLevel == 1)
  486. .OrderBy(x => x.SortNo)
  487. .Select(x => new DashboardTabModel(x.MetricCode, x.MetricName, x.SortNo))
  488. .ToList();
  489. }
  490. public class DashboardPageSaveDto
  491. {
  492. public string? NavigationMode { get; set; }
  493. public string? TabSource { get; set; }
  494. public string? DefaultTabMetricCode { get; set; }
  495. public List<DashboardWidgetSaveDto>? Widgets { get; set; }
  496. }
  497. public class DashboardWidgetSaveDto
  498. {
  499. public string? SectionCode { get; set; }
  500. public string? TabMetricCode { get; set; }
  501. public string? WidgetCode { get; set; }
  502. public string? WidgetType { get; set; }
  503. public string? Title { get; set; }
  504. public int MetricLevel { get; set; }
  505. public List<string>? MetricCodes { get; set; }
  506. public int Span { get; set; }
  507. public int SortNo { get; set; }
  508. public string? StyleJson { get; set; }
  509. }
  510. private sealed record DashboardWidgetModel(
  511. string SectionCode,
  512. string? TabMetricCode,
  513. string WidgetCode,
  514. string WidgetType,
  515. string? Title,
  516. int MetricLevel,
  517. List<string> MetricCodes,
  518. int Span,
  519. int SortNo,
  520. string? StyleJson);
  521. private sealed record DashboardTabModel(string MetricCode, string Title, int SortNo);
  522. private sealed record DashboardMetricValueModel(
  523. int Level,
  524. decimal? MetricValue,
  525. decimal? TargetValue,
  526. string? StatusColor,
  527. string? TrendFlag,
  528. List<DashboardTrendPointModel> Trend);
  529. private sealed record DashboardTrendPointModel(string Date, decimal? Value);
  530. private sealed class DashboardValueRow
  531. {
  532. public string? MetricCode { get; set; }
  533. public decimal? MetricValue { get; set; }
  534. public decimal? TargetValue { get; set; }
  535. public string? StatusColor { get; set; }
  536. public string? TrendFlag { get; set; }
  537. }
  538. private sealed class DashboardTrendRow
  539. {
  540. public string? MetricCode { get; set; }
  541. public DateTime BizDate { get; set; }
  542. public decimal? MetricValue { get; set; }
  543. }
  544. }