ChatBIService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. using System.Text;
  2. using Admin.NET.Plugin.AiDOP.Entity;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. namespace Admin.NET.Plugin.AiDOP.ChatBI;
  5. public sealed class ChatBIService : ITransient
  6. {
  7. private static readonly string[] ValueTables =
  8. {
  9. "ado_s9_kpi_value_l1_day",
  10. "ado_s9_kpi_value_l2_day",
  11. "ado_s9_kpi_value_l3_day",
  12. "ado_s9_kpi_value_l4_day"
  13. };
  14. private readonly ISqlSugarClient _db;
  15. private readonly DeepSeekChatClient _deepSeek;
  16. public ChatBIService(ISqlSugarClient db, DeepSeekChatClient deepSeek)
  17. {
  18. _db = db;
  19. _deepSeek = deepSeek;
  20. }
  21. public async Task<ChatBIAnswerOutput> AskAsync(ChatBIAskInput input, long tenantId, CancellationToken cancellationToken = default)
  22. {
  23. var question = string.IsNullOrWhiteSpace(input.Question) ? "当前最需要关注的问题是什么?" : input.Question.Trim();
  24. var moduleCode = NormalizeModuleCode(input.ModuleCode);
  25. var intent = ClassifyIntent(question, moduleCode);
  26. var allMetrics = await LoadMetricCardsAsync(tenantId, input.FactoryId <= 0 ? 1 : input.FactoryId, moduleCode);
  27. var focus = ResolveFocusMetric(allMetrics, question, intent);
  28. var hasExplicitMetric = HasExplicitMetricMention(allMetrics, question);
  29. var metrics = BuildContextMetrics(allMetrics, focus, intent, moduleCode, hasExplicitMetric);
  30. var contextTitle = moduleCode == null ? "九宫格全局运营看板" : $"{moduleCode} {ResolveModuleName(moduleCode)}";
  31. var fallback = BuildDeterministicAnswer(question, contextTitle, intent, focus, metrics);
  32. var llmAnswer = await TryBuildDeepSeekAnswerAsync(question, contextTitle, input.Filters, fallback, metrics, cancellationToken);
  33. if (!string.IsNullOrWhiteSpace(llmAnswer))
  34. {
  35. fallback.Source = "deepseek";
  36. fallback.IsFallback = false;
  37. fallback.AnswerText = llmAnswer;
  38. fallback.Summary = FirstSentence(llmAnswer);
  39. }
  40. fallback.ContextTitle = contextTitle;
  41. fallback.Actions = BuildActions(moduleCode, focus ?? metrics.FirstOrDefault());
  42. fallback.Suggestions = BuildSuggestions(moduleCode, intent, focus ?? metrics.FirstOrDefault());
  43. return fallback;
  44. }
  45. private async Task<List<ChatBIMetricCard>> LoadMetricCardsAsync(long tenantId, long factoryId, string? moduleCode)
  46. {
  47. var kpiQuery = _db.Queryable<AdoSmartOpsKpiMaster>()
  48. .Where(x => x.TenantId == tenantId && x.IsEnabled);
  49. if (!string.IsNullOrWhiteSpace(moduleCode))
  50. kpiQuery = kpiQuery.Where(x => x.ModuleCode == moduleCode);
  51. var kpis = await kpiQuery
  52. .OrderBy(x => x.MetricLevel)
  53. .OrderBy(x => x.SortNo)
  54. .ToListAsync();
  55. var values = await LoadCurrentValuesAsync(tenantId, factoryId, moduleCode);
  56. var cards = kpis
  57. .Select(kpi =>
  58. {
  59. values.TryGetValue(kpi.MetricCode, out var value);
  60. var current = value?.MetricValue;
  61. var target = value?.TargetValue;
  62. var status = AidopS4KpiMerge.AchievementLevel(
  63. current,
  64. target,
  65. kpi.Direction ?? "higher_is_better",
  66. kpi.YellowThreshold,
  67. kpi.RedThreshold);
  68. var gap = AidopS4KpiMerge.GapValue(current, target);
  69. return new ChatBIMetricCard
  70. {
  71. Id = kpi.Id,
  72. ParentId = kpi.ParentId,
  73. ModuleCode = kpi.ModuleCode,
  74. MetricCode = kpi.MetricCode,
  75. MetricName = kpi.MetricName,
  76. MetricLevel = kpi.MetricLevel,
  77. CurrentValue = current,
  78. TargetValue = target,
  79. Unit = kpi.Unit ?? "",
  80. StatusColor = status,
  81. GapLabel = FormatGapLabel(gap, kpi.Unit),
  82. Department = kpi.Department ?? "",
  83. TrendFlag = value?.TrendFlag ?? ""
  84. };
  85. })
  86. .Where(x => x.CurrentValue != null || x.TargetValue != null)
  87. .ToList();
  88. return cards;
  89. }
  90. private async Task<Dictionary<string, MetricValueRow>> LoadCurrentValuesAsync(long tenantId, long factoryId, string? moduleCode)
  91. {
  92. var result = new Dictionary<string, MetricValueRow>(StringComparer.OrdinalIgnoreCase);
  93. for (var i = 0; i < ValueTables.Length; i++)
  94. {
  95. var table = ValueTables[i];
  96. try
  97. {
  98. var bizDate = await ResolveMaxBizDateAsync(table, tenantId, factoryId, moduleCode);
  99. if (bizDate == null) continue;
  100. var sql = string.IsNullOrWhiteSpace(moduleCode)
  101. ? $"""
  102. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
  103. FROM {table}
  104. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0 AND biz_date=@bizDate
  105. """
  106. : $"""
  107. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
  108. FROM {table}
  109. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date=@bizDate
  110. """;
  111. var rows = await _db.Ado.SqlQueryAsync<MetricValueRow>(sql, new { tenantId, factoryId, moduleCode, bizDate });
  112. foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)))
  113. {
  114. row.Level = i + 1;
  115. result[row.MetricCode!] = row;
  116. }
  117. }
  118. catch
  119. {
  120. // Demo 读取允许某一层指标值表暂缺,避免 ChatBI 整体不可用。
  121. }
  122. }
  123. return result;
  124. }
  125. private async Task<DateTime?> ResolveMaxBizDateAsync(string table, long tenantId, long factoryId, string? moduleCode)
  126. {
  127. var sql = string.IsNullOrWhiteSpace(moduleCode)
  128. ? $"SELECT MAX(biz_date) FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0"
  129. : $"SELECT MAX(biz_date) FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0";
  130. return await _db.Ado.GetDateTimeAsync(sql, new { tenantId, factoryId, moduleCode });
  131. }
  132. private async Task<string?> TryBuildDeepSeekAnswerAsync(
  133. string question,
  134. string contextTitle,
  135. Dictionary<string, string>? filters,
  136. ChatBIAnswerOutput deterministic,
  137. List<ChatBIMetricCard> metrics,
  138. CancellationToken cancellationToken)
  139. {
  140. if (metrics.Count == 0) return null;
  141. var systemPrompt = """
  142. 你是 Ai-DOP 制造运营 ChatBI 助手。后端已经完成意图识别、指标选择和事实校验。
  143. 你的任务只是把“确定性回答结构”润色成清晰中文,不得新增未给出的原因、单据、负责人或数值。
  144. 如果用户问题已经指定某个指标,只能围绕焦点指标和 KPI 聚合摘要中列出的真实下级指标回答,不得引用同模块其它兄弟指标。
  145. 请按「结论 / 证据 / 可能原因 / 下一步」四段输出,每段 1 到 2 句,总字数控制在 220 字以内。
  146. """;
  147. var userPrompt = new StringBuilder()
  148. .AppendLine($"入口:{contextTitle}")
  149. .AppendLine($"用户问题:{question}")
  150. .AppendLine($"识别意图:{deterministic.Intent}")
  151. .AppendLine($"焦点指标:{deterministic.FocusMetricCode}")
  152. .AppendLine($"筛选条件:{FormatFilters(filters)}")
  153. .AppendLine("确定性回答结构:");
  154. foreach (var section in deterministic.Sections)
  155. userPrompt.AppendLine($"- {section.Title}:{section.Content}");
  156. userPrompt
  157. .AppendLine("KPI 聚合摘要:");
  158. foreach (var m in metrics.Take(10))
  159. {
  160. userPrompt.AppendLine(
  161. $"- {m.ModuleCode} {m.MetricName}({m.MetricCode}, L{m.MetricLevel}):当前 {FormatValue(m.CurrentValue, m.Unit)},目标 {FormatValue(m.TargetValue, m.Unit)},状态 {StatusText(m.StatusColor)},期量差 {m.GapLabel},责任 {FallbackText(m.Department, "未配置")}");
  162. }
  163. return await _deepSeek.CompleteAsync(systemPrompt, userPrompt.ToString(), cancellationToken);
  164. }
  165. private static ChatBIAnswerOutput BuildDeterministicAnswer(
  166. string question,
  167. string contextTitle,
  168. string intent,
  169. ChatBIMetricCard? focus,
  170. List<ChatBIMetricCard> metrics)
  171. {
  172. var sections = BuildSections(question, contextTitle, intent, focus, metrics);
  173. var answer = sections.Count == 0
  174. ? $"已收到问题“{question}”。当前 {contextTitle} 暂未读取到可用于分析的 KPI 聚合值,请先确认指标日值表是否有数据。"
  175. : string.Join("\n", sections.Select(x => $"{x.Title}:{x.Content}"));
  176. return new ChatBIAnswerOutput
  177. {
  178. Source = "local",
  179. IsFallback = true,
  180. Intent = intent,
  181. FocusMetricCode = focus?.MetricCode ?? "",
  182. Summary = focus == null ? "暂未读取到 KPI 聚合数据" : $"重点关注:{focus.MetricName}",
  183. AnswerText = answer,
  184. Sections = sections,
  185. Metrics = metrics
  186. };
  187. }
  188. private static List<ChatBIAction> BuildActions(string? moduleCode, ChatBIMetricCard? focus)
  189. {
  190. var primaryModule = moduleCode ?? focus?.ModuleCode ?? "S1";
  191. var metricCode = focus?.MetricLevel == 1 ? focus.MetricCode : null;
  192. return new List<ChatBIAction>
  193. {
  194. new() { Label = $"查看 {primaryModule} 看板", Url = $"/aidop/smart-ops/{primaryModule.ToLowerInvariant()}" },
  195. new()
  196. {
  197. Label = "打开智慧诊断",
  198. Url = string.IsNullOrWhiteSpace(metricCode)
  199. ? $"/aidop/smart-diagnosis?module={primaryModule}"
  200. : $"/aidop/smart-diagnosis?module={primaryModule}&metricCode={metricCode}"
  201. }
  202. };
  203. }
  204. private static List<string> BuildSuggestions(string? moduleCode, string intent, ChatBIMetricCard? focus)
  205. {
  206. if (moduleCode == null)
  207. {
  208. return new List<string>
  209. {
  210. "当前全局最严重的问题是什么?",
  211. "哪些模块出现红灯指标?",
  212. focus == null ? "S1 产销协同有什么风险?" : $"{focus.ModuleCode} 的 {focus.MetricName} 为什么异常?"
  213. };
  214. }
  215. return new List<string>
  216. {
  217. $"{moduleCode} 当前最严重的指标是什么?",
  218. focus == null ? "订单评审周期为什么红了?" : $"{focus.MetricName} 为什么异常?",
  219. intent == "improvement_plan" ? "如何验证改善是否有效?" : "下一步应该创建什么改善计划?"
  220. };
  221. }
  222. private static string ClassifyIntent(string question, string? moduleCode)
  223. {
  224. if (ContainsAny(question, "改善", "计划", "措施", "下一步", "怎么处理", "怎么办")) return "improvement_plan";
  225. if (ContainsAny(question, "为什么", "原因", "异常", "红", "黄", "没达标", "未达标")) return "root_cause";
  226. if (ContainsAny(question, "趋势", "变化", "近", "最近", "连续")) return "trend_summary";
  227. return moduleCode == null ? "global_bottleneck" : "metric_status";
  228. }
  229. private static ChatBIMetricCard? ResolveFocusMetric(List<ChatBIMetricCard> metrics, string question, string intent)
  230. {
  231. var exact = metrics
  232. .Select(x => new { Metric = x, Score = MetricMatchScore(question, x) })
  233. .Where(x => x.Score > 0)
  234. .OrderByDescending(x => x.Score)
  235. .ThenByDescending(x => StatusRank(x.Metric.StatusColor))
  236. .ThenBy(x => x.Metric.MetricLevel)
  237. .FirstOrDefault();
  238. if (exact != null) return exact.Metric;
  239. var preferredLevels = intent == "global_bottleneck" ? new[] { 1, 2, 3, 4 } : new[] { 1, 2, 3, 4 };
  240. return metrics
  241. .OrderByDescending(x => StatusRank(x.StatusColor))
  242. .ThenBy(x => Array.IndexOf(preferredLevels, x.MetricLevel))
  243. .ThenBy(x => x.MetricCode)
  244. .FirstOrDefault();
  245. }
  246. private static List<ChatBIMetricCard> BuildContextMetrics(
  247. List<ChatBIMetricCard> allMetrics,
  248. ChatBIMetricCard? focus,
  249. string intent,
  250. string? moduleCode,
  251. bool hasExplicitMetric)
  252. {
  253. if (focus == null)
  254. return allMetrics.OrderByDescending(x => StatusRank(x.StatusColor)).ThenBy(x => x.MetricLevel).Take(moduleCode == null ? 9 : 10).ToList();
  255. var sameModule = allMetrics.Where(x => string.Equals(x.ModuleCode, focus.ModuleCode, StringComparison.OrdinalIgnoreCase)).ToList();
  256. var context = new List<ChatBIMetricCard> { focus };
  257. if (hasExplicitMetric)
  258. {
  259. context.AddRange(sameModule
  260. .Where(x => x.MetricCode != focus.MetricCode && IsDescendantOf(x, focus, sameModule))
  261. .OrderByDescending(x => StatusRank(x.StatusColor))
  262. .ThenBy(x => x.MetricLevel)
  263. .ThenBy(x => x.MetricCode)
  264. .Take(9));
  265. return context
  266. .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
  267. .Select(x => x.First())
  268. .Take(10)
  269. .ToList();
  270. }
  271. if (intent is "root_cause" or "improvement_plan")
  272. {
  273. context.AddRange(sameModule
  274. .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel >= focus.MetricLevel)
  275. .OrderByDescending(x => StatusRank(x.StatusColor))
  276. .ThenBy(x => x.MetricLevel)
  277. .Take(7));
  278. }
  279. else if (moduleCode == null)
  280. {
  281. context.AddRange(allMetrics
  282. .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel == 1)
  283. .OrderByDescending(x => StatusRank(x.StatusColor))
  284. .ThenBy(x => x.ModuleCode)
  285. .Take(8));
  286. }
  287. else
  288. {
  289. context.AddRange(sameModule
  290. .Where(x => x.MetricCode != focus.MetricCode)
  291. .OrderByDescending(x => StatusRank(x.StatusColor))
  292. .ThenBy(x => x.MetricLevel)
  293. .Take(7));
  294. }
  295. return context
  296. .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
  297. .Select(x => x.First())
  298. .Take(10)
  299. .ToList();
  300. }
  301. private static List<ChatBIAnswerSection> BuildSections(
  302. string question,
  303. string contextTitle,
  304. string intent,
  305. ChatBIMetricCard? focus,
  306. List<ChatBIMetricCard> metrics)
  307. {
  308. if (focus == null) return new List<ChatBIAnswerSection>();
  309. var relatedRisks = metrics
  310. .Where(x => x.MetricCode != focus.MetricCode && StatusRank(x.StatusColor) >= 2)
  311. .OrderByDescending(x => StatusRank(x.StatusColor))
  312. .ThenBy(x => x.MetricLevel)
  313. .Take(3)
  314. .ToList();
  315. var riskText = relatedRisks.Count == 0
  316. ? "当前上下文未发现更多红黄下层指标,需进入智慧诊断继续看明细证据。"
  317. : "相关红黄指标包括:" + string.Join("、", relatedRisks.Select(x => $"{x.MetricName}({StatusText(x.StatusColor)})")) + "。";
  318. var nextAction = intent switch
  319. {
  320. "improvement_plan" => $"建议围绕 {focus.MetricName} 建立改善计划,目标值先按 {FormatValue(focus.TargetValue, focus.Unit)} 对齐,并把红黄下层指标作为行动项来源。",
  321. "root_cause" => $"建议打开智慧诊断,从 {focus.MetricName} 下钻到 L2/L3/L4,确认责任部门、卡点和单据后再创建改善计划。",
  322. "trend_summary" => $"建议结合近 7 到 14 天趋势复核 {focus.MetricName} 是否连续恶化,再决定是否升级为改善任务。",
  323. _ => $"建议优先跟进 {focus.MetricName},若持续红黄则进入智慧诊断并创建改善任务。"
  324. };
  325. return new List<ChatBIAnswerSection>
  326. {
  327. new()
  328. {
  329. Title = "结论",
  330. Tone = StatusRank(focus.StatusColor) >= 3 ? "danger" : StatusRank(focus.StatusColor) == 2 ? "warning" : "info",
  331. Content = $"{contextTitle} 当前焦点是 {focus.MetricName},状态为{StatusText(focus.StatusColor)}。"
  332. },
  333. new()
  334. {
  335. Title = "证据",
  336. Tone = "info",
  337. Content = $"{focus.MetricName} 当前 {FormatValue(focus.CurrentValue, focus.Unit)},目标 {FormatValue(focus.TargetValue, focus.Unit)},期量差 {FallbackText(focus.GapLabel, "-")}。"
  338. },
  339. new()
  340. {
  341. Title = "可能原因",
  342. Tone = relatedRisks.Count > 0 ? "warning" : "info",
  343. Content = riskText
  344. },
  345. new()
  346. {
  347. Title = "下一步",
  348. Tone = "success",
  349. Content = nextAction
  350. }
  351. };
  352. }
  353. private static string? NormalizeModuleCode(string? moduleCode)
  354. {
  355. var mc = (moduleCode ?? "").Trim().ToUpperInvariant();
  356. return mc is "S1" or "S2" or "S3" or "S4" or "S5" or "S6" or "S7" or "S9" ? mc : null;
  357. }
  358. private static bool QuestionMatchesMetric(string question, ChatBIMetricCard metric)
  359. {
  360. if (string.IsNullOrWhiteSpace(question)) return false;
  361. return question.Contains(metric.MetricName, StringComparison.OrdinalIgnoreCase)
  362. || question.Contains(metric.MetricCode, StringComparison.OrdinalIgnoreCase);
  363. }
  364. private static bool HasExplicitMetricMention(List<ChatBIMetricCard> metrics, string question)
  365. {
  366. return metrics.Any(metric => QuestionMatchesMetric(question, metric));
  367. }
  368. private static bool IsDescendantOf(ChatBIMetricCard candidate, ChatBIMetricCard ancestor, List<ChatBIMetricCard> sameModule)
  369. {
  370. var byId = sameModule.Where(x => x.Id > 0).ToDictionary(x => x.Id);
  371. var parentId = candidate.ParentId;
  372. while (parentId != null)
  373. {
  374. if (parentId.Value == ancestor.Id) return true;
  375. if (!byId.TryGetValue(parentId.Value, out var parent)) return false;
  376. parentId = parent.ParentId;
  377. }
  378. return false;
  379. }
  380. private static int MetricMatchScore(string question, ChatBIMetricCard metric)
  381. {
  382. var score = 0;
  383. if (QuestionMatchesMetric(question, metric)) score += 100;
  384. foreach (var token in SplitMetricName(metric.MetricName))
  385. {
  386. if (token.Length >= 2 && question.Contains(token, StringComparison.OrdinalIgnoreCase)) score += 10;
  387. }
  388. return score;
  389. }
  390. private static IEnumerable<string> SplitMetricName(string metricName)
  391. {
  392. var separators = new[] { ' ', '/', '-', '_', '(', ')', '(', ')', ':', ':', '、' };
  393. return (metricName ?? "").Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
  394. }
  395. private static int StatusRank(string? status) => (status ?? "").ToLowerInvariant() switch
  396. {
  397. "red" => 3,
  398. "yellow" => 2,
  399. "green" => 1,
  400. _ => 0
  401. };
  402. private static string StatusText(string? status) => (status ?? "").ToLowerInvariant() switch
  403. {
  404. "red" => "红灯",
  405. "yellow" => "黄灯",
  406. "green" => "绿灯",
  407. _ => "未知"
  408. };
  409. private static string ResolveModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch
  410. {
  411. "S1" => "产销协同动态详情看板",
  412. "S2" => "制造协同动态详情看板",
  413. "S3" => "供应协同动态详情看板",
  414. "S4" => "采购执行动态详情看板",
  415. "S5" => "物料仓储动态详情看板",
  416. "S6" => "生产执行动态详情看板",
  417. "S7" => "成品仓储动态详情看板",
  418. "S9" => "运营指标动态详情看板",
  419. _ => "动态详情看板"
  420. };
  421. private static bool ContainsAny(string text, params string[] needles)
  422. {
  423. return needles.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase));
  424. }
  425. private static string FormatGapLabel(decimal? gap, string? unit)
  426. {
  427. if (gap == null) return "";
  428. var rounded = decimal.Round(gap.Value, 2);
  429. return $"{rounded:0.##}{unit ?? ""}";
  430. }
  431. private static string FormatValue(decimal? value, string? unit)
  432. {
  433. if (value == null) return "-";
  434. return $"{decimal.Round(value.Value, 2):0.##}{unit ?? ""}";
  435. }
  436. private static string FormatFilters(Dictionary<string, string>? filters)
  437. {
  438. if (filters == null || filters.Count == 0) return "未填写条件(展示全部)";
  439. return string.Join(";", filters.Where(x => !string.IsNullOrWhiteSpace(x.Value)).Select(x => $"{x.Key}={x.Value}"));
  440. }
  441. private static string FallbackText(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value;
  442. private static string FirstSentence(string text)
  443. {
  444. var trimmed = text.Trim();
  445. var idx = trimmed.IndexOfAny(new[] { '。', '!', '?', '\n' });
  446. return idx > 0 ? trimmed[..Math.Min(idx + 1, trimmed.Length)] : trimmed;
  447. }
  448. private sealed class MetricValueRow
  449. {
  450. public int Level { get; set; }
  451. public string? MetricCode { get; set; }
  452. public decimal? MetricValue { get; set; }
  453. public decimal? TargetValue { get; set; }
  454. public string? StatusColor { get; set; }
  455. public string? TrendFlag { get; set; }
  456. }
  457. }