AidopKanbanController.DashboardPage.cs 27 KB

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