AidopKanbanController.Generic.cs 24 KB

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