AidopKanbanController.Generic.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using SqlSugar;
  4. namespace Admin.NET.Plugin.AiDOP.Controllers;
  5. public partial class AidopKanbanController
  6. {
  7. // S4.cs 中的 FormatGapLabel / ResolveMaxBizDateAsync 是 private,在同一 partial 类里可直接复用
  8. /// <summary>
  9. /// 通用运营布局(L1/L2/L3/L4)。moduleCode 指定模块;S4 也可走这个端点。
  10. /// </summary>
  11. [HttpGet("operation-layout/{moduleCode}")]
  12. public async Task<IActionResult> GetOperationLayoutGeneric(string moduleCode, [FromQuery] long factoryId = 1)
  13. {
  14. var mc = (moduleCode ?? "").ToUpperInvariant();
  15. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  16. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  17. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  18. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  19. .ToListAsync()).FirstOrDefault();
  20. var items = await _db.Queryable<AdoSmartOpsLayoutItem>()
  21. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1)
  22. .OrderBy(x => x.SortNo)
  23. .ToListAsync();
  24. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  25. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  26. .ToListAsync();
  27. var kpiByCode = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  28. object Row(AdoSmartOpsLayoutItem r)
  29. {
  30. kpiByCode.TryGetValue(r.MetricCode, out var k);
  31. return new
  32. {
  33. rowId = r.RowId,
  34. metricCode = r.MetricCode,
  35. metricLevel = r.MetricLevel,
  36. displayName = string.IsNullOrWhiteSpace(r.DisplayName) ? k?.MetricName : r.DisplayName,
  37. sortNo = r.SortNo,
  38. parentRowId = r.ParentRowId,
  39. formulaText = r.FormulaText,
  40. panelZone = r.PanelZone,
  41. unit = k?.Unit,
  42. direction = k?.Direction
  43. };
  44. }
  45. return Ok(new
  46. {
  47. moduleCode = mc,
  48. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  49. l1 = items.Where(x => x.MetricLevel == 1).Select(Row).ToList(),
  50. l2 = items.Where(x => x.MetricLevel >= 2).Select(Row).ToList()
  51. });
  52. }
  53. /// <summary>
  54. /// 通用九宫格/首页(L1 合并日表)。moduleCode 指定模块。
  55. /// </summary>
  56. [HttpGet("home-grid/{moduleCode}")]
  57. public async Task<IActionResult> GetHomeGridGeneric(string moduleCode, [FromQuery] long factoryId = 1)
  58. {
  59. var mc = (moduleCode ?? "").ToUpperInvariant();
  60. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  61. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  62. if (mc == "S8")
  63. await _s8KpiSnapshot.RefreshAsync(tenantId, factoryId);
  64. var layout = await _db.Queryable<AdoSmartOpsLayoutItem>()
  65. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel == 1)
  66. .OrderBy(x => x.SortNo)
  67. .ToListAsync();
  68. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  69. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  70. .ToListAsync();
  71. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  72. var bizDate = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId, mc);
  73. const string valSql = """
  74. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue
  75. FROM ado_s9_kpi_value_l1_day v
  76. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  77. AND v.biz_date=(
  78. SELECT MAX(v2.biz_date)
  79. FROM ado_s9_kpi_value_l1_day v2
  80. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  81. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0
  82. )
  83. """;
  84. var vals = await _db.Ado.SqlQueryAsync<S4ValRow>(valSql, new { t = tenantId, f = factoryId, m = mc });
  85. var valBy = vals.ToDictionary(x => x.MetricCode ?? "", StringComparer.OrdinalIgnoreCase);
  86. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  87. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  88. .ToListAsync()).FirstOrDefault();
  89. var items = new List<object>();
  90. foreach (var row in layout)
  91. {
  92. kpiBy.TryGetValue(row.MetricCode, out var k);
  93. valBy.TryGetValue(row.MetricCode, out var v);
  94. var cur = v?.MetricValue;
  95. var tgt = v?.TargetValue;
  96. var dir = k?.Direction ?? "higher_is_better";
  97. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  98. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  99. var arrow = AidopS4KpiMerge.GapArrow(gap);
  100. var label = FormatGapLabelGeneric(gap, k?.Unit);
  101. items.Add(new
  102. {
  103. rowId = row.RowId,
  104. metricCode = row.MetricCode,
  105. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  106. unit = k?.Unit ?? "",
  107. currentValue = cur,
  108. targetValue = tgt,
  109. gapValue = gap,
  110. gapLabel = label,
  111. gapArrow = arrow,
  112. achievementLevel = level,
  113. formulaText = row.FormulaText
  114. });
  115. }
  116. return Ok(new
  117. {
  118. moduleCode = mc,
  119. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  120. bizDate = bizDate.ToString("yyyy-MM-dd"),
  121. items
  122. });
  123. }
  124. /// <summary>
  125. /// 通用详情 KPI(L2/L3/L4 合并日表),可按 panelZone 过滤。
  126. /// </summary>
  127. [HttpGet("detail-kpis/{moduleCode}")]
  128. public async Task<IActionResult> GetDetailKpisGeneric(string moduleCode, [FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
  129. {
  130. var mc = (moduleCode ?? "").ToUpperInvariant();
  131. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  132. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  133. if (mc == "S8")
  134. await _s8KpiSnapshot.RefreshAsync(tenantId, factoryId);
  135. var q = _db.Queryable<AdoSmartOpsLayoutItem>()
  136. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel >= 2);
  137. if (!string.IsNullOrWhiteSpace(panelZone))
  138. q = q.Where(x => x.PanelZone == panelZone);
  139. var layout = await q.OrderBy(x => x.SortNo).ToListAsync();
  140. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  141. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  142. .ToListAsync();
  143. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  144. var bizDateL2 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId, mc);
  145. var bizDateL3 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId, mc);
  146. AidopTenantMigration.EnsureKpiValueL4Table(_db);
  147. var bizDateL4 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l4_day", tenantId, factoryId, mc);
  148. var bizDate = new[] { bizDateL2, bizDateL3, bizDateL4 }.Max();
  149. const string valSqlL2 = """
  150. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue
  151. FROM ado_s9_kpi_value_l2_day v
  152. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  153. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l2_day v2
  154. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  155. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  156. """;
  157. const string valSqlL3 = """
  158. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue
  159. FROM ado_s9_kpi_value_l3_day v
  160. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  161. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l3_day v2
  162. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  163. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  164. """;
  165. const string valSqlL4 = """
  166. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue
  167. FROM ado_s9_kpi_value_l4_day v
  168. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  169. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l4_day v2
  170. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  171. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  172. """;
  173. var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, m = mc });
  174. var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, m = mc });
  175. var valsL4 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL4, new { t = tenantId, f = factoryId, m = mc });
  176. var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
  177. foreach (var r in valsL2)
  178. if (!string.IsNullOrEmpty(r.MetricCode))
  179. valBy[r.MetricCode] = r;
  180. foreach (var r in valsL3)
  181. if (!string.IsNullOrEmpty(r.MetricCode))
  182. valBy[r.MetricCode] = r;
  183. foreach (var r in valsL4)
  184. if (!string.IsNullOrEmpty(r.MetricCode))
  185. valBy[r.MetricCode] = r;
  186. var items = new List<object>();
  187. foreach (var row in layout)
  188. {
  189. kpiBy.TryGetValue(row.MetricCode, out var k);
  190. valBy.TryGetValue(row.MetricCode, out var v);
  191. var cur = v?.MetricValue;
  192. var tgt = v?.TargetValue;
  193. var dir = k?.Direction ?? "higher_is_better";
  194. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  195. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  196. var arrow = AidopS4KpiMerge.GapArrow(gap);
  197. items.Add(new
  198. {
  199. rowId = row.RowId,
  200. metricCode = row.MetricCode,
  201. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  202. unit = k?.Unit ?? "",
  203. currentValue = cur,
  204. targetValue = tgt,
  205. gapValue = gap,
  206. gapLabel = FormatGapLabelGeneric(gap, k?.Unit),
  207. gapArrow = arrow,
  208. achievementLevel = level,
  209. formulaText = row.FormulaText,
  210. parentRowId = row.ParentRowId,
  211. panelZone = row.PanelZone
  212. });
  213. }
  214. return Ok(new { moduleCode = mc, bizDate = bizDate.ToString("yyyy-MM-dd"), items });
  215. }
  216. private async Task<DateTime> ResolveMaxBizDateGenericAsync(string table, long tenantId, long factoryId, string moduleCode)
  217. {
  218. var sql = $"""
  219. SELECT MAX(biz_date) AS MaxBiz FROM {table}
  220. WHERE tenant_id=@t AND module_code=@m AND is_deleted=0
  221. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=@f)
  222. AND (factory_id IS NULL OR factory_id<>@t)
  223. """;
  224. var rows = await _db.Ado.SqlQueryAsync<GenericMaxDateRow>(sql, new { t = tenantId, f = factoryId, m = moduleCode });
  225. return rows.FirstOrDefault()?.MaxBiz ?? DateTime.Today;
  226. }
  227. private static string FormatGapLabelGeneric(decimal? gap, string? unit)
  228. {
  229. if (gap == null) return "—";
  230. var g = gap.Value;
  231. var sign = g >= 0 ? "+" : "";
  232. var s = $"{sign}{Math.Round(g, 2)}";
  233. if (unit == "%")
  234. return s + "%";
  235. return s;
  236. }
  237. private sealed class GenericMaxDateRow
  238. {
  239. public DateTime? MaxBiz { get; set; }
  240. }
  241. /// <summary>
  242. /// 通用指标字典(按模块)。与 s4-metric-catalog 等价,moduleCode 必填。
  243. /// </summary>
  244. [HttpGet("metric-catalog/{moduleCode}")]
  245. public async Task<IActionResult> GetMetricCatalogGeneric(string moduleCode, [FromQuery] int? metricLevel = null)
  246. {
  247. var mc = (moduleCode ?? "").ToUpperInvariant();
  248. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  249. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  250. var q = _db.Queryable<AdoSmartOpsKpiMaster>()
  251. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled);
  252. if (metricLevel is > 0)
  253. q = q.Where(x => x.MetricLevel == metricLevel);
  254. var list = await q.OrderBy(x => x.SortNo).ToListAsync();
  255. return Ok(list.Select(x => new
  256. {
  257. id = x.Id,
  258. metricCode = x.MetricCode,
  259. moduleCode = x.ModuleCode,
  260. metricLevel = x.MetricLevel,
  261. defaultName = x.MetricName,
  262. unit = x.Unit,
  263. direction = x.Direction,
  264. sortHint = x.SortNo,
  265. parentId = x.ParentId,
  266. description = x.Description,
  267. formula = x.Formula,
  268. formulaExpr = x.FormulaExpr,
  269. formulaPreview = x.FormulaPreview,
  270. calcRule = x.CalcRule,
  271. dataSource = x.DataSource,
  272. statFrequency = x.StatFrequency,
  273. department = x.Department
  274. }));
  275. }
  276. public class GenericLayoutSaveDto
  277. {
  278. public List<GenericLayoutL1Dto>? L1 { get; set; }
  279. public List<GenericLayoutL2Dto>? L2 { get; set; }
  280. /// <summary>
  281. /// 九宫格首页模块卡呈现样式(card_grid | table_list | 未来新增)。
  282. /// 可选:未传或空值时保持 HomeModule 当前值;首次无记录时回落默认 card_grid。
  283. /// </summary>
  284. public string? LayoutPattern { get; set; }
  285. }
  286. /// <summary>
  287. /// 允许的 LayoutPattern 白名单。新增样式时在此增加条目(与前端 homeCardStyles.ts 注册表保持一致)。
  288. /// </summary>
  289. private static readonly HashSet<string> AllowedLayoutPatterns = new(StringComparer.OrdinalIgnoreCase)
  290. {
  291. "card_grid",
  292. "table_list",
  293. "tile_cards",
  294. };
  295. public class GenericLayoutL1Dto
  296. {
  297. public string? RowId { get; set; }
  298. public string? MetricCode { get; set; }
  299. public string? DisplayName { get; set; }
  300. public int SortNo { get; set; }
  301. public string? FormulaText { get; set; }
  302. }
  303. public class GenericLayoutL2Dto
  304. {
  305. public string? RowId { get; set; }
  306. public string? MetricCode { get; set; }
  307. public string? DisplayName { get; set; }
  308. public int SortNo { get; set; }
  309. public string? ParentRowId { get; set; }
  310. public string? FormulaText { get; set; }
  311. public string? PanelZone { get; set; }
  312. }
  313. /// <summary>
  314. /// 通用保存运营布局:按 moduleCode 保存 L1/L2 到 LayoutItem;同步 KpiMaster.DisplayName。
  315. /// 约束:L1 最多 MaxL1PerModule;必须命中该 moduleCode 的字典项。
  316. /// </summary>
  317. [HttpPut("operation-layout/{moduleCode}")]
  318. public async Task<IActionResult> PutOperationLayoutGeneric(string moduleCode, [FromBody] GenericLayoutSaveDto? body, [FromQuery] long factoryId = 1)
  319. {
  320. var mc = (moduleCode ?? "").ToUpperInvariant();
  321. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  322. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  323. if (body?.L1 == null || body.L2 == null)
  324. return BadRequest(new { message = "L1/L2 不能为空" });
  325. if (body.L1.Count > MaxL1PerModule)
  326. return BadRequest(new { message = $"L1 最多 {MaxL1PerModule} 个" });
  327. var kpiAll = await _db.Queryable<AdoSmartOpsKpiMaster>()
  328. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  329. .ToListAsync();
  330. var kpiByCode = kpiAll.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  331. foreach (var r in body.L1)
  332. {
  333. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel != 1)
  334. return BadRequest(new { message = $"无效的 L1 指标: {r.MetricCode}" });
  335. }
  336. var l1Ids = new HashSet<string>(StringComparer.Ordinal);
  337. foreach (var r in body.L1)
  338. {
  339. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  340. if (!l1Ids.Add(rid))
  341. return BadRequest(new { message = $"重复的 L1 RowId: {rid}" });
  342. }
  343. foreach (var r in body.L2)
  344. {
  345. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
  346. return BadRequest(new { message = $"无效的 L2/L3/L4 指标: {r.MetricCode}" });
  347. }
  348. var now = DateTime.Now;
  349. try
  350. {
  351. _db.Ado.BeginTran();
  352. await _db.Deleteable<AdoSmartOpsLayoutItem>()
  353. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  354. .ExecuteCommandAsync();
  355. var rows = new List<AdoSmartOpsLayoutItem>();
  356. foreach (var r in body.L1)
  357. {
  358. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  359. rows.Add(new AdoSmartOpsLayoutItem
  360. {
  361. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  362. RowId = rid, MetricLevel = 1, MetricCode = r.MetricCode!.Trim(),
  363. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  364. SortNo = r.SortNo, ParentRowId = null,
  365. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText.Trim(),
  366. PanelZone = null, IsEnabled = 1, UpdateTime = now
  367. });
  368. }
  369. foreach (var r in body.L2)
  370. {
  371. var code = r.MetricCode!.Trim();
  372. var level = kpiByCode.TryGetValue(code, out var kk) ? kk.MetricLevel : 2;
  373. var prefix = $"{mc}-L{level}-";
  374. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{prefix}{code}" : r.RowId.Trim();
  375. var p = string.IsNullOrWhiteSpace(r.ParentRowId) ? null : r.ParentRowId.Trim();
  376. rows.Add(new AdoSmartOpsLayoutItem
  377. {
  378. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  379. RowId = rid, MetricLevel = level, MetricCode = code,
  380. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  381. SortNo = r.SortNo, ParentRowId = p,
  382. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText,
  383. PanelZone = string.IsNullOrWhiteSpace(r.PanelZone) ? null : r.PanelZone.Trim(),
  384. IsEnabled = 1, UpdateTime = now
  385. });
  386. }
  387. if (rows.Count > 0)
  388. await _db.Insertable(rows).ExecuteCommandAsync();
  389. foreach (var r in body.L1.Concat<object>(body.L2).Cast<dynamic>())
  390. {
  391. string? code = (string?)r.MetricCode?.Trim();
  392. string? dn = (string?)r.DisplayName?.Trim();
  393. if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(dn)) continue;
  394. if (!kpiByCode.TryGetValue(code, out var kpi)) continue;
  395. if (!string.Equals(kpi.MetricName, dn, StringComparison.Ordinal))
  396. {
  397. await _db.Updateable<AdoSmartOpsKpiMaster>()
  398. .SetColumns(x => new AdoSmartOpsKpiMaster { MetricName = dn, UpdatedAt = now })
  399. .Where(x => x.Id == kpi.Id)
  400. .ExecuteCommandAsync();
  401. }
  402. }
  403. var lp = (body.LayoutPattern ?? "").Trim();
  404. if (!string.IsNullOrEmpty(lp) && !AllowedLayoutPatterns.Contains(lp))
  405. return BadRequest(new { message = $"无效的 LayoutPattern: {body.LayoutPattern}" });
  406. var lpNormalized = string.IsNullOrEmpty(lp) ? null : lp.ToLowerInvariant();
  407. int hmRows;
  408. if (lpNormalized != null)
  409. {
  410. hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
  411. .SetColumns(x => new AdoSmartOpsHomeModule { LayoutPattern = lpNormalized, UpdateTime = now })
  412. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  413. .ExecuteCommandAsync();
  414. }
  415. else
  416. {
  417. hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
  418. .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
  419. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  420. .ExecuteCommandAsync();
  421. }
  422. if (hmRows == 0)
  423. {
  424. await _db.Insertable(new AdoSmartOpsHomeModule
  425. {
  426. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  427. LayoutPattern = lpNormalized ?? "card_grid", UpdateTime = now
  428. }).ExecuteCommandAsync();
  429. }
  430. _db.Ado.CommitTran();
  431. }
  432. catch (Exception ex)
  433. {
  434. _db.Ado.RollbackTran();
  435. return BadRequest(new { message = ex.Message });
  436. }
  437. return Ok(new { ok = true });
  438. }
  439. }